How I Use 1Password with External Secrets Operator, and the Boundary I Got Wrong
I already trusted 1Password with my family's passwords. External Secrets Operator gave it a second job on my home server, and exposed a bootstrap boundary I still need to fix.
I was using 1Password long before I needed it to deliver secrets into a Kubernetes cluster.
My family account is where I keep the ordinary credentials that accumulate around a life: logins, shared accounts, and the things I would rather not have to remember. Before 1Password, I used Dashlane and at least one other password manager whose name has completely left me. That is probably not the strongest possible endorsement of it.
1Password was different mostly because it stayed easy to use. It worked well enough, consistently enough, that I did not have to keep thinking about the password manager itself. Over the years, that reliability turned it into one of the pieces of software I trusted.
What surprised me later was that 1Password did not have to stop at the edge of personal password management. It could also take part in the infrastructure I was building at home.
The problem my home server created
My home server is managed through Git and Argo CD. Git contains the Kubernetes resource names, namespaces, chart versions, and mappings between services and the credentials they expect. The credential values do not belong there.
As the server grew, so did the list of values to protect: database passwords, API tokens, storage credentials, private keys, and application secrets. I could have introduced a separate secret-management product. That would also have been another service to learn, protect, recover, and remember to maintain.
I had already done the slow part with 1Password. I trusted it, knew how to use it, and already paid for it through my family account. I created a dedicated home-server vault and an External Secrets Operator service account through that same account.
For new machine credentials, I prefer one credential per 1Password item. It keeps references and revocation easy to understand. Some existing home-server integrations group related fields, such as a database username and password, in one item. That is the history of the system; the one-credential convention is how I prefer to add new machine-facing credentials.
What I use, and what I got wrong
External Secrets Operator connects 1Password to Kubernetes. Argo CD installs ESO and applies its custom resources. A ClusterSecretStore configures the 1Password SDK provider. Each ExternalSecret selects one or more properties and tells ESO how to create a Kubernetes Secret for the consumer.
I chose ESO because it fit the GitOps boundary I already had. Git and Argo CD could declare which credential goes where while the values stayed in 1Password, and ESO could materialize the Kubernetes Secrets that existing consumers expect. The 1Password SDK provider also talks to 1Password without a separate 1Password Connect service. That let me reuse the secret authority I already trusted instead of adopting another complete secret-management platform.
I use 1Password through ESO on my home server. The concrete resources and mappings below come from the current GitOps repository, so they describe declared state rather than a fresh cluster-health check.
The service account is authorized to a vault, not merely to the field named by one ExternalSecret. Anyone who can create a suitable ExternalSecret and read its resulting Kubernetes Secret can request other data that service account may read. That fact exposed a flaw in my current arrangement.
The repository currently declares this bootstrap path:
The boundary my repository currently declares
Scroll sideways to follow the flow.
The token item is in the same custom vault the service account can read. An authorized ExternalSecret author could request ESO's provider token, materialize it in Kubernetes, and use it outside the cluster. Putting a replacement token beside it during rotation would reproduce the problem.
The safer target is:
The safer boundary I am moving toward
Scroll sideways to follow the flow.
The bootstrap credential enters Kubernetes from outside Git and is not retrieved through ESO. This migration is still pending in my setup. Writing the guide is what made the misplaced boundary obvious; I have not changed the home-server repository, 1Password account, or cluster to make the story tidier.
One real credential: Cloudflare to cert-manager
This is the path declared in my home-server repository. cert-manager uses a Cloudflare API token to complete DNS-01 challenges:
From Argo CD and 1Password to the wildcard Certificate
Scroll sideways to follow the flow.
The private domain, email address, vault name, Git owner, 1Password item title, and wildcard Certificate identity in this article are sanitized.
The 1Password item is an API Credential. Its built-in credential field contains the Cloudflare token. ESO maps that field to the api-token key in a Secret named cloudflare-api-token, which the ClusterIssuer references. cert-manager recommends an API token instead of the global API key and documents Zone - DNS - Edit plus Zone - Zone - Read. Its current example applies those permissions to all zones. Restricting the token to a particular zone is a separate least-privilege choice that should be tested against actual issuance; the repository does not establish that my current token uses that narrower scope.
Reproducing the design
The walkthrough assumes that Argo CD already exists. You also need kubectl, a human-authenticated 1Password CLI session, and an Argo CD AppProject that permits the repositories, cluster, and namespaces below.
Blocks labelled repository excerpt match the current repository except for stated sanitization. An operator step happens outside Git. A pending procedure has been checked without using the live cluster.
These values are environment-specific:
| Example value | Replace or verify |
|---|---|
argocd |
Namespace containing Argo CD Application objects |
spec.project: home |
Existing AppProject with the necessary permissions |
git@github.com:YOUR-ORG/home-server.git |
Repository URL |
gitops/01-core/external-secretsgitops/01-core/cert-managergitops/01-core/traefik |
Repository paths |
2.9.0 |
ESO chart version |
v1.21.1 |
cert-manager chart version |
Infrastructure Secrets |
Custom infrastructure vault |
Private |
Human operator's built-in vault |
external-secrets, cert-manager, edge |
Kubernetes namespaces; edge sanitizes the real Certificate namespace |
cloudflare-dns-token |
Sanitized 1Password item title |
wildcard-example-net, wildcard-example-net-tls |
Sanitized Certificate and TLS Secret names |
example.net, admin@example.net |
Sanitized DNS zone and ACME email |
As of October 2026, my repository pins ESO chart version 2.9.0 and uses external-secrets.io/v1. Check the current ESO installation guide, 1Password SDK provider documentation, and upgrade notes before copying those versions.
I created my service account through the same 1Password family account I use personally. As of October 2026, 1Password documents service accounts and account-specific limits for Families accounts. Verify availability and your permission to create one against the current service-account requirements and rate-limit documentation.
Set the 1Password access boundaries
Create a custom infrastructure vault for data ESO may fetch. Create a service account in 1Password's Developer area with read access to only that vault. Do not grant write access or permission to create vaults for this use.
Save its token as an API Credential item named home-server-eso-bootstrap, with the token in the built-in credential field. Put that item in the human operator's built-in Private vault, or in a separate bootstrap vault that is never granted to the service account. 1Password says built-in Personal, Private, Employee, and default Shared vaults cannot be granted to service accounts, which makes Private a useful boundary. The creation wizard shows the token once, so save it immediately.
Create the Cloudflare API Credential item in the infrastructure vault. In this article its sanitized title is cloudflare-dns-token and the token is in credential.
Vault grants and permissions are immutable after a service account is created. If they need to change, create a new service account. If they stay the same, 1Password supports rotating the existing account's token; that distinction matters later.
Install ESO through Argo CD
Repository excerpt: this Application installs ESO and its CRDs. Only comments have been omitted.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: external-secrets
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: home
source:
repoURL: https://charts.external-secrets.io
chart: external-secrets
targetRevision: 2.9.0
helm:
valuesObject:
installCRDs: true
destination:
server: https://kubernetes.default.svc
namespace: external-secrets
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
CreateNamespace=true declares the external-secrets namespace. In my app-of-apps layout, this file lives at gitops/01-core/external-secrets/application.yaml.
kubectl get namespace external-secrets
kubectl -n external-secrets get deployment,pods
kubectl get crd clustersecretstores.external-secrets.io
kubectl get crd externalsecrets.external-secrets.io
Seed ESO's bootstrap credential
Operator step, pending in my setup: set OP_ACCOUNT to the human account shorthand, sign-in address, account ID, or user ID shown by op account list. The explicit account selector matters when more than one 1Password account is configured. The environment cleanup prevents an inherited service-account or Connect credential from silently changing which identity performs the read.
Private is a vault title, not a cross-account identity. If that title is ambiguous or has been localized, get the intended vault ID with op vault list --account "$OP_ACCOUNT" and use that ID in the first segment of the op:// reference. 1Password secret references accept names or unique IDs.
Run the command only after the ESO namespace exists. It does not overwrite an existing Secret.
OP_ACCOUNT='your-human-1password-account' /bin/bash <<'BASH'
set -euo pipefail
set +x
unset OP_SERVICE_ACCOUNT_TOKEN OP_CONNECT_HOST OP_CONNECT_TOKEN
eso_token=''
trap 'unset eso_token' EXIT
if [[ -z "${OP_ACCOUNT:-}" ]]; then
printf '%s\n' 'error: OP_ACCOUNT must select the human 1Password account' >&2
exit 1
fi
eso_token="$(op read --account "$OP_ACCOUNT" \
'op://Private/home-server-eso-bootstrap/credential')"
if [[ -z "$eso_token" ]]; then
printf '%s\n' 'error: 1Password returned an empty service-account token' >&2
exit 1
fi
printf '%s' "$eso_token" |
kubectl create secret generic onepassword-sa-token \
--namespace external-secrets \
--from-file=token=/dev/stdin
unset eso_token
trap - EXIT
BASH
Check only metadata and key names:
kubectl -n external-secrets get secret onepassword-sa-token \
-o go-template='{{.metadata.name}}{{"\n"}}{{range $key, $_ := .data}}{{println $key}}{{end}}'
The output should contain onepassword-sa-token and token, never the value.
Declare the store in its own Application
Repository excerpt, repository URL sanitized: the second Application owns the raw store resource.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: external-secrets-store
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: home
source:
repoURL: git@github.com:YOUR-ORG/home-server.git
path: gitops/01-core/external-secrets
targetRevision: main
directory:
recurse: false
exclude: '{application.yaml,application-*.yaml}'
destination:
server: https://kubernetes.default.svc
namespace: external-secrets
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Repository excerpt, vault name sanitized: the store uses the manually seeded Secret.
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: onepassword
spec:
provider:
onepasswordSDK:
vault: "Infrastructure Secrets"
cache:
ttl: 6h
maxSize: 128
auth:
serviceAccountSecretRef:
name: onepassword-sa-token
namespace: external-secrets
key: token
My root Application is configured to create the two External Secrets child Applications, external-secrets and external-secrets-store, automatically. Their sync waves order the child Application resources; they do not wait for a human to seed the bootstrap Secret. On a new cluster the store may therefore be temporarily NotReady until the Secret appears, after which ESO can reconcile it. The bootstrap remains operator-controlled.
The six-hour cache is a current request-budget mitigation, not a general recommendation. ESO calls the cache experimental and warns that long TTLs can return stale data. Its longer-term effectiveness in my environment is not yet established.
kubectl wait --for=condition=Ready \
clustersecretstore/onepassword \
--timeout=120s
kubectl get clustersecretstore onepassword
Reconcile cert-manager and its raw resources
My root app-of-apps is configured to discover the child Application manifests under gitops/01-core. The diagram above shows the relevant ownership chain: the chart Application owns cert-manager and its CRDs, the issuer Application owns the Cloudflare ExternalSecret and both issuers, and traefik-config owns the wildcard Certificate.
Repository excerpts, Git owner sanitized: the chart Application installs cert-manager and creates its namespace. The issuer Application reconciles the raw YAML in the same directory while excluding the flattened Application manifests.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cert-manager
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: home
source:
repoURL: https://charts.jetstack.io
chart: cert-manager
targetRevision: v1.21.1
helm:
valuesObject:
crds:
enabled: true
destination:
server: https://kubernetes.default.svc
namespace: cert-manager
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cert-manager-issuers
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: home
source:
repoURL: git@github.com:YOUR-ORG/home-server.git
path: gitops/01-core/cert-manager
targetRevision: main
directory:
recurse: false
exclude: '{application.yaml,application-*.yaml}'
destination:
server: https://kubernetes.default.svc
namespace: cert-manager
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
As of October 2026, the repository pins cert-manager chart version v1.21.1. Check the current release and upgrade documentation before copying it. If your cert-manager Application does not create the namespace, declare cert-manager before applying the namespaced resources that follow.
A ClusterIssuer is cluster-scoped, but cert-manager reads its referenced Secrets from the configured cluster resource namespace, which defaults to cert-manager. The repository therefore places the ExternalSecret and its generated Secret there.
Deliver the Cloudflare token
Repository excerpt, item title sanitized: this ExternalSecret is owned by cert-manager-issuers.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: cloudflare-api-token
namespace: cert-manager
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
secretStoreRef:
name: onepassword
kind: ClusterSecretStore
refreshInterval: 1h
target:
name: cloudflare-api-token
creationPolicy: Owner
data:
- secretKey: api-token
remoteRef:
key: cloudflare-dns-token/credential
Repository excerpt, email and zone sanitized: staging and production use different ACME endpoints and account-key Secrets, but both read the same Cloudflare Secret and key.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
acme:
email: admin@example.net
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- example.net
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
acme:
email: admin@example.net
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- example.net
Reconcile the wildcard Certificate
The Certificate lives with the Traefik configuration in my GitOps layout. Repository excerpts, Git owner and private identities sanitized: the namespace, Certificate name, TLS Secret name, and domain are changed while the repository structure is preserved.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: traefik-config
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "2"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: home
source:
repoURL: git@github.com:YOUR-ORG/home-server.git
path: gitops/01-core/traefik
targetRevision: main
directory:
recurse: false
exclude: '{application.yaml,application-*.yaml}'
destination:
server: https://kubernetes.default.svc
namespace: edge
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-example-net
namespace: edge
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
secretName: wildcard-example-net-tls
dnsNames:
- example.net
- "*.example.net"
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
group: cert-manager.io
It is the wildcard Certificate my repository declares, with private identities changed. The production issuer follows the shared cloudflare-api-token reference back through the ExternalSecret to cloudflare-dns-token/credential in 1Password.
Verify the path without exposing the value
Check the store, synchronization status, and generated Secret shape:
kubectl get clustersecretstore onepassword
kubectl -n cert-manager wait \
--for=condition=Ready externalsecret/cloudflare-api-token \
--timeout=120s
kubectl -n cert-manager get externalsecret cloudflare-api-token
kubectl -n cert-manager get secret cloudflare-api-token \
-o go-template='type={{.type}}{{"\n"}}name={{.metadata.name}}{{"\n"}}keys={{range $key, $_ := .data}}{{printf "%s " $key}}{{end}}{{"\n"}}'
The expected data key is api-token. Do not decode it for a routine check.
Then check both issuers and the sanitized Certificate shown above:
kubectl get clusterissuer letsencrypt-staging letsencrypt-prod
CERT_NAMESPACE='edge'
CERT_NAME='wildcard-example-net'
kubectl -n "$CERT_NAMESPACE" get certificate "$CERT_NAME"
kubectl -n "$CERT_NAMESPACE" describe certificate "$CERT_NAME"
kubectl -n "$CERT_NAMESPACE" get certificaterequest,order,challenge
A recent successful DNS-01 issuance or renewal exercises the Cloudflare credential. A Certificate that has merely remained Ready is weaker evidence because it may be using previously materialized state.
Rotating the bootstrap credential without fooling myself
1Password's current rotation procedure generates a new token for the same service account and permissions. In the web app, open Developer, select the service account, choose Rotate Token, and set an expiration for the current token long enough to update and test the replacement. Save the new token into Private or the excluded bootstrap vault. There is no need to create a new service account unless the vault access or permissions must change.
Pending procedure: after the new token has been saved, replace the Kubernetes value with the same defensive handling. Retrieval must succeed before kubectl apply can touch the working Secret.
OP_ACCOUNT='your-human-1password-account' /bin/bash <<'BASH'
set -euo pipefail
set +x
unset OP_SERVICE_ACCOUNT_TOKEN OP_CONNECT_HOST OP_CONNECT_TOKEN
replacement_token=''
trap 'unset replacement_token' EXIT
if [[ -z "${OP_ACCOUNT:-}" ]]; then
printf '%s\n' 'error: OP_ACCOUNT must select the human 1Password account' >&2
exit 1
fi
replacement_token="$(op read --account "$OP_ACCOUNT" \
'op://Private/home-server-eso-bootstrap/credential')"
if [[ -z "$replacement_token" ]]; then
printf '%s\n' 'error: 1Password returned an empty replacement token' >&2
exit 1
fi
printf '%s' "$replacement_token" |
kubectl create secret generic onepassword-sa-token \
--namespace external-secrets \
--from-file=token=/dev/stdin \
--dry-run=client -o yaml |
kubectl apply -f -
unset replacement_token
trap - EXIT
BASH
Here is the part I cannot yet turn into a tested production runbook. With a six-hour provider cache, an ExternalSecret can remain Ready, its Secret can exist, and its consumer can work without proving that ESO has authenticated with the replacement token. Forcing an ordinary refresh is not enough if the requested value is still cached. I have not established an authoritative, tested fresh-auth check for this provider setup.
That means I would not choose a short expiration window, or revoke the old token, based only on those green statuses. The overlap must be long enough for a controlled test that demonstrates an uncached provider read using the replacement. Until I have exercised and documented that test, this rotation section remains a proposed procedure rather than a runbook.
For my pending migration, the obsolete token item should leave the service-readable infrastructure vault only after that fresh-auth test and the old token's safe expiry or revocation. Until then, the flawed boundary still exists.
Application credentials have another clock. My Cloudflare ExternalSecret is configured to reconcile hourly, the provider cache may serve its old value for six hours, and the final consumer may not reload a changed Secret automatically. Rotation is complete only when the consumer demonstrably uses the new credential.
Why I still use it
This arrangement does not make Kubernetes Secrets harmless. They still need appropriate RBAC, encryption-at-rest decisions, protected backups, and care around who can create ExternalSecret resources. A cluster-wide store is a large trust boundary, and ESO's bootstrap credential needs its own recovery and rotation procedure.
Those limitations did not undo the original choice. 1Password remains where I know how to inspect, replace, and recover a credential, while Git records the non-secret wiring that delivers it.
Infrastructure has a way of collecting products that are individually sensible and exhausting in combination. Reusing 1Password kept the work of secret management in a place I already knew.