← all writing

Using Bitwarden to manage every secret in my homelab cluster

Every Kubernetes cluster eventually runs into the same question. Where do the secrets live? You can put them in Kubernetes Secrets, which are not actually secret. They're base64-encoded, readable to anyone with namespace access, and stored in etcd. You can put them in .env files in git, which is a bad idea even when those files are gitignored, because gitignored and never accidentally committed are not the same thing. Or you can put them in a real secrets store and pull them into the cluster on demand.

The default advice for the third option goes one of two ways. AWS Secrets Manager, if you're already paying for AWS. HashiCorp Vault, if you've got the appetite for running a Vault cluster (which most homelab operators do not). Both are real answers. Both also cost more attention than most people running a single cluster at home want to give them.

There's a third path that almost nobody documents: Bitwarden, the password manager you probably already pay for, ships a separate product called Secrets Manager. It speaks the same API contracts as the big-name secret stores. And the open-source external-secrets-operator for Kubernetes supports it as a first-class backend.

I migrated every secret in my cluster to it over a weekend. Here's the shape.

#Two pieces have to exist before any of this matters

The first piece is external-secrets-operator (ESO). It's an operator that runs inside the cluster and watches for custom resources called ExternalSecret. Each one of those resources says, in effect: "go fetch this remote value, put it in a Kubernetes Secret with this name, and keep it in sync." The actual remote-fetching is done by a "secret store" backend, which is configurable per resource or per cluster. AWS Secrets Manager is one backend. Vault is another. GCP Secret Manager. Azure Key Vault. And, more recently, Bitwarden Secrets Manager.

The second piece is the Bitwarden side. Bitwarden Secrets Manager is a separate product from the password manager most people know, but it lives in the same web account. You sign in to bitwarden.com, switch to the Secrets Manager tab (it's a tab next to Password Manager), and create a project. A project holds secrets, the way a folder holds files. Each secret has a name, a value, and a UUID. The UUID is the part that matters, because that's what your Kubernetes manifests reference.

You then create a "machine account" inside Bitwarden Secrets Manager (think of this as a service account), and give that machine account read access to the project. The machine account has an access token. That token is the one piece of secret material you have to hand-place into the cluster. Everything else flows from it.

#Wiring it together

ESO ships a Helm chart. Install it. Separately, install a small helper called bitwarden-sdk-server, also via Helm, which is the actual thing that talks to Bitwarden's API. The reason for the helper is that Bitwarden's API uses an SDK that doesn't directly fit ESO's plugin interface, so the SDK server bridges between them.

Once both are running, you create a ClusterSecretStore resource:

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: bitwarden-homelab
spec:
  provider:
    bitwardensecretsmanager:
      apiURL: https://api.bitwarden.com
      identityURL: https://identity.bitwarden.com
      auth:
        secretRef:
          credentials:
            name: bitwarden-access-token
            key: token
            namespace: external-secrets
      organizationID: <your org UUID>
      projectID: <your project UUID>
      bitwardenServerSDKURL: http://bitwarden-sdk-server.external-secrets.svc:9998

This says: "there's a Bitwarden Secrets Manager out there at this address; here's the credential to talk to it; the bridge service is in-cluster at this URL." The secretRef references a regular Kubernetes Secret containing the machine account's access token. That Secret you create by hand, exactly once, with kubectl create secret. It's the only hand-placed credential in the whole setup.

After that, every workload that needs a secret just creates an ExternalSecret:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: minio-root
  namespace: minio
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: bitwarden-homelab
    kind: ClusterSecretStore
  target:
    name: minio-root
    creationPolicy: Owner
  data:
    - secretKey: rootUser
      remoteRef:
        key: 8a1e3f57-e784-4ac4-baf4-b4570157d808
    - secretKey: rootPassword
      remoteRef:
        key: dbcfdbd8-e91f-4467-a5b2-b4570157d876

ESO reads this, fetches the values from Bitwarden by UUID, writes them into a regular Kubernetes Secret named minio-root, and refreshes them every hour. The workload's Deployment just references minio-root like any other Secret. The workload doesn't know or care that Bitwarden is involved.

#Why the UUIDs are not actually sensitive

The UUIDs in the manifest above are not secrets. They're opaque references to entries in a secret store that requires its own credentials to read. An attacker who steals my YAML files learns the names of my secrets, which is a tiny information leak (it tells them I have a MinIO root credential, which they could have guessed). They cannot use those UUIDs to read the values. To do that they'd need the access token, which lives in exactly one place: a Kubernetes Secret in the external-secrets namespace, populated once by hand from a file at /etc/bitwarden-token on a single bastion machine.

This means the YAML files can live in a public git repo without leaking anything material. The actual secret material is gated behind one token, and that token is the only thing I have to protect with the usual care.

#What it actually buys you

The migration from "secrets in gitignored YAML" to "secrets in Bitwarden + ExternalSecret manifests" is mostly bureaucratic. You write more YAML. You add an operator. You add a bridge service. You hand-place one token.

What you get back is a lot of small things that add up. Secret rotation is one place to do it, not twenty. Every secret has an audit trail (Bitwarden logs every read). Every secret has a name that means something, instead of being whatever environment variable a workload happened to want. New workloads can be created entirely through git, no kubectl create secret ever, because the ExternalSecret manifest in git is enough to fetch the value at deploy time.

The biggest thing it buys is one I didn't fully appreciate until I'd done it: the secret values stop existing in your editor history, in your shell history, in your screen-share recordings, in the .bak.20260418-* backup files that compose-up writes when you change something. They live in Bitwarden, behind a single access boundary, and the manifests that reference them are durable and shareable.

For a homelab operator the calculation is: do you trust Bitwarden as your single source of secret truth? You probably already do, for everything else in your life that has a password. Extending that trust boundary to cover Kubernetes Secrets is not as big a leap as it sounds.

#What I'd still flag

A few things I want to mention before someone runs this exact setup. The free tier of Bitwarden Secrets Manager has a cap of three projects per organization. That covers most homelabs (one "homelab" project is enough), but if you find yourself needing more, that's the upgrade.

The bridge service (bitwarden-sdk-server) is the dependency between you and your secrets. If it's down, ESO can't fetch new secrets. Existing Secrets in the cluster keep working, but rotation stops. Run it on the same node where ESO runs, with adequate restart policies.

And: the access token, which is your one bootstrap credential, expires by default on a schedule you set in Bitwarden. Pick a long expiry (180 days or a year), put a calendar reminder, and rotate it deliberately rather than letting it surprise you in the middle of an unrelated incident.

None of those are dealbreakers. They're the things I wish I'd known on hour zero, and now you know them on hour zero too.


← all writing