Pulling Kubernetes secrets from Bitwarden with External Secrets Operator

For a long time my homelab secrets workflow was “type kubectl create secret and try not to lose the value.” That works until you rebuild the cluster, change something across many namespaces, or forget which Deployment is the source of truth for a token.

I finally replaced that pattern with External Secrets Operator (ESO) pointed at Bitwarden Secrets Manager. Bitwarden is the canonical secret store; Kubernetes gets normal Secret objects generated from it.

This post is mostly a future reference for the Bitwarden side, which has a few non-obvious clicks I do not want to figure out next time.

Why Bitwarden Secrets Manager

I evaluated a few backends: AWS Secrets Manager, AWS Systems Manager Parameter Store, Sealed Secrets, OpenBao, and Bitwarden Secrets Manager. Constraints from my homelab:

  • Cost. Anything with per-secret or per-API-call billing is hard to reason about at homelab volume. I want a flat free tier or a clear monthly cap.
  • Network. I do not want every workload reaching out to some remote server. Either the secrets need to flow through a cluster-local cache, or the backend itself needs to live near the cluster.
  • Operability. I do not want to run a stateful secret store myself if I can avoid it. That ruled out OpenBao which I was considering for now.

Bitwarden Secrets Manager fit because it is a separate product from the password manager but still lives in the same Bitwarden organization. The free plan was enough for my homelab at the time of setup, and ESO has first-class support for it through the bitwardensecretsmanager provider.

How the pieces fit

k8s_external_secrets

A workload never knows about Bitwarden. It mounts a normal Kubernetes Secret. ESO keeps that Secret in sync with the upstream value on a refresh interval, defaulting to one hour unless the ExternalSecret says otherwise.

Bitwarden side

This is the section I am writing this post for. The Bitwarden Secrets Manager UI is a different app from the password manager, with its own data model, and the click path is not obvious if you are coming in cold.

1. Confirm Secrets Manager is enabled for the org

Open https://vault.bitwarden.com, log in, then go to Settings > Subscription. If you only see Password Manager line items, click Add Secrets Manager. Check the current Bitwarden Secrets Manager plan limits, because pricing and plan details can change.

2. Switch to the Secrets Manager UI

Use the product picker in the top-left, next to the Bitwarden logo, then select Secrets Manager. The URL changes to vault.bitwarden.com/#/sm/<org-id>/.... Same organization, different app, different schema.

3. Create a project

Go to Projects > New project. Name it something durable like homelab. Projects are the unit of access control: machine accounts get permission per project, not per secret.

After creating the project, the URL is https://vault.bitwarden.com/#/sm/<org-id>/projects/<project-id>. Save both UUIDs. You will paste them into the ClusterSecretStore later.

4. Create a machine account

Go to Machine accounts > New machine account. Name it for the consumer, such as k8s-eso. A machine account is the identity ESO uses to talk to the API.

5. Grant the machine account access to the project

Open the machine account, go to its Projects tab, and assign the homelab project. Read access is enough for pull-only sync. Use write access only if you plan to use ESO PushSecret.

If you skip this, the machine account can authenticate to the API, but secret lookups fail because no project is in scope. In practice that can look like failed to get secret: API error: [404 Not Found], which is not especially friendly.

6. Generate an access token

In the same machine account, go to Access tokens > New access token. Name it for the environment, set an expiration that matches your rotation policy, and copy the token immediately. It is shown exactly once. If you lose it, generate a new one; there is no way to retrieve it.

7. Drop a test secret in

Go to Secrets > New secret. Name it eso-test, set the value to hello-world, and assign it to the homelab project. An unassigned secret is invisible to the machine account regardless of project access on the machine account itself. Save the secret and grab the secret UUID from the URL.

You should now have four values:

ValueWhere it goes
Org UUIDClusterSecretStore.spec.provider.bitwardensecretsmanager.organizationID
Project UUIDClusterSecretStore.spec.provider.bitwardensecretsmanager.projectID
Access tokenSecret/bitwarden-access-token (manual, never in Git)
Test secret UUIDExternalSecret.spec.data[].remoteRef.key

ESO can reference Bitwarden secrets by UUID or name, but UUIDs are safer because names are not guaranteed to be unique. If you rename the secret in Bitwarden, the UUID stays and the ExternalSecret keeps working. If you delete and recreate it, the UUID changes and the ExternalSecret needs an update.


Cluster side

In order to set up Kubernetes resources, I use ArgoCD. I commit the configuration values for the upstream helm chart and ArgoCD deploys the chart with my values. Check out a previous post if you would like to walk through how to set up a similar pattern with ArgoCD.

A standard multi-source Argo CD app has three pieces: the upstream Helm chart, values from Git, and a templates directory:

argo-apps/apps/external-secrets/
  app.yaml
  values.yaml
  templates/
    bitwarden-sdk-tls-certificate.yaml
    bitwarden-cluster-secret-store.yaml

The Helm chart deploys the controller, webhook, cert controller, and the bitwarden-sdk-server subchart. Values keep it deliberately small:

installCRDs: true

bitwarden-sdk-server:
  enabled: true
  resources:
    requests:
      cpu: 10m
      memory: 64Mi

# Metrics and ServiceMonitor wiring omitted here.

The interesting part is the SDK server’s TLS. The Bitwarden provider talks to bitwarden-sdk-server over HTTPS, and ESO validates that certificate through caBundle or caProvider. You can self-sign manually, but if the cluster already has cert-manager, let it own the certificate lifecycle. Check out a previous post to see how I set up cert-manager with my AWS DNS domain.

Letting cert-manager mint the SDK server cert

A self-signed Issuer plus a Certificate with the right SANs replaces the manual OpenSSL bootstrap:

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: bitwarden-sdk-selfsigned
  namespace: external-secrets
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: bitwarden-sdk-server
  namespace: external-secrets
spec:
  secretName: bitwarden-tls-certs
  issuerRef:
    name: bitwarden-sdk-selfsigned
    kind: Issuer
  dnsNames:
    - bitwarden-sdk-server
    - bitwarden-sdk-server.external-secrets.svc
    - bitwarden-sdk-server.external-secrets.svc.cluster.local
  duration: 87600h # 10y, internal cert trusted through caProvider
  renewBefore: 8760h # 1y
  privateKey:
    algorithm: RSA
    size: 4096

cert-manager produces Secret/bitwarden-tls-certs with tls.crt, tls.key, and ca.crt. ESO references the same secret as a caProvider, so it trusts the certificate chain. Renewal is automatic.

Tradeoff: this app now depends on cert-manager being healthy before it can sync. If cert-manager is down, the SDK server pod can block on its volume mount and ClusterSecretStore will report NotReady. For a homelab that is fine; for production, weigh the dependency carefully.

The ClusterSecretStore

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: bitwarden
spec:
  provider:
    bitwardensecretsmanager:
      apiURL: https://api.bitwarden.com
      identityURL: https://identity.bitwarden.com
      bitwardenServerSDKURL: https://bitwarden-sdk-server.external-secrets.svc.cluster.local:9998
      organizationID: <org-uuid>
      projectID: <project-uuid>
      caProvider:
        type: Secret
        name: bitwarden-tls-certs
        key: ca.crt
        namespace: external-secrets
      auth:
        secretRef:
          credentials:
            name: bitwarden-access-token
            key: token
            namespace: external-secrets

Three references all land in the external-secrets namespace: the access token secret, the CA cert secret, and the SDK server URL. ClusterSecretStore is cluster-scoped, so every namespaced reference must be fully qualified.

Including projectID scopes the store to one project. Omitting it widens the store to every project the machine account can read. I prefer one project and one machine account per environment; it keeps blast radius obvious.

Bootstrapping the access token without leaking it to history

kubectl create namespace external-secrets

# macOS: read from clipboard, strip whitespace, never pass the token as an arg
kubectl -n external-secrets create secret generic bitwarden-access-token \
  --from-file=token=<(pbpaste | tr -d '[:space:]')

pbcopy </dev/null

kubectl -n external-secrets get secret bitwarden-access-token \
  -o jsonpath='{.data.token}' | base64 -d | wc -c

If you are not on macOS, read -rs BW_TOKEN works equivalently. The important part is not putting the token directly in shell history.

Using a secret

The ExternalSecret is the per-namespace consumer. Apply it wherever you need the value to land:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: my-app-api-key
  namespace: apps
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: bitwarden
  target:
    name: my-app-api-key
  data:
    - secretKey: api-key
      remoteRef:
        key: <bitwarden-secret-uuid>

ESO writes Secret/my-app-api-key in apps, with key api-key, value pulled from the Bitwarden secret with the matching UUID. Mount it into your workload as you would any other Kubernetes secret.

The refreshInterval controls how often ESO re-pulls. If you need a faster sync after rotating a value upstream, annotate to force it:

kubectl -n apps annotate externalsecret my-app-api-key \
  force-sync=$(date +%s) --overwrite

Production notes

For production, think carefully about tenancy and rotation. A ClusterSecretStore is convenient, but it allows any namespace with permission to create ExternalSecret resources to request data from that store. If that is too broad, use namespace-scoped SecretStore resources, separate Bitwarden projects, separate machine accounts, or ESO controller classes.

The Bitwarden access token is still a bootstrap secret in the cluster. Keep Kubernetes secret encryption enabled, restrict RBAC around Secret and ExternalSecret objects, and rotate the machine account token on a schedule you can actually keep.

Verification

# operator and SDK server
kubectl -n external-secrets get pods
kubectl -n external-secrets get certificate bitwarden-sdk-server

# the store is happy
kubectl get clustersecretstore bitwarden
# NAME        AGE   STATUS   CAPABILITIES   READY
# bitwarden   1m    Valid    ReadWrite      True

# end-to-end with a test ExternalSecret
kubectl apply -f /tmp/eso-test.yaml
kubectl -n default get externalsecret eso-test
# NAME       STORETYPE            STORE       REFRESH INTERVAL   STATUS         READY   LAST SYNC
# eso-test   ClusterSecretStore   bitwarden   1h                 SecretSynced   True    6s

kubectl -n default get secret eso-test -o jsonpath='{.data.hello}' | base64 -d
# hello-world

If the ExternalSecret is Ready=False:

  • SecretSyncedError ... [404 Not Found]: the machine account is not assigned to the secret’s project, or the secret is not assigned to a project the machine account can see.
  • SecretSyncedError ... [401 Unauthorized]: the access token is wrong, expired, or revoked. Recreate the bootstrap secret.
  • failed to perform http request, ... connection refused: the SDK server pod is not running. Check kubectl -n external-secrets get pods and kubectl -n external-secrets logs -l app.kubernetes.io/name=bitwarden-sdk-server.

What you get

A clean handoff between the secret authority and the consumer. To rotate a credential, change it in Bitwarden; every ExternalSecret referencing it picks up the new value within refreshInterval. To onboard a new app to a managed credential, write an ExternalSecret. To audit who reads what, the access happens through a machine account whose token you control.

The cluster never holds long-lived API credentials in YAML. The only secret in the cluster that is not itself an ExternalSecret is the bootstrap access token, and rotating that token is one cluster-side update.


Sources