In my homelab, I’ve been experimenting with k3s, FluxCD and different ways to manage secrets in Kubernetes. Secrets are critical — they hold credentials, API keys, and certificates that applications need — but they’re also tricky to rotate and keep in sync with workloads.

I’ve tried tools like Sealed Secrets and SOPS which work well with GitOps and Flux, but I wanted to explore something more dynamic and centralized. That’s where OpenBao came in. Paired with the External Secrets Operator and Reloader, I found a workflow that feels simple, secure, and natural.

OpenBao

For this experiment I deployed OpenBao in my cluster. Since OpenBao is a community-driven fork of HashiCorp Vault, it works in a very similar way.

To keep things simple, I created a test secret in a KV store:

# create the test kv
bao secrets enable -path=test kv

# add the secret
bao kv put -mount=test test SERVER_NAME=:80 TEST=success
openbao secret

Then, I had to configure OpenBao so that External Secrets could authenticate securely. The easiest way to do this is with an AppRole.

First, enable the AppRole auth method:

bao auth enable approle

Then, create a policy that gives read access to the secrets you want to expose. For example, for the newly created kv store:

# test-read-policy.hcl
path "test/*" {
  capabilities = ["read"]
}

Write the policy into OpenBao:

bao policy write test-read-policy test-read-policy.hcl

Now create the AppRole and assign the policy:

bao write auth/approle/role/external-secrets \
    policies="test-read-policy" \
    secret_id_ttl=10m \
    token_num_uses=0 \ # fine for a homelab use, not for prod
    token_ttl=20m \
    token_max_ttl=30m \
    secret_id_num_uses=0 # fine for a homelab use, not for prod

Fetch the RoleID and SecretID (these are what External Secrets Operator will use):

bao read auth/approle/role/external-secrets/role-id
bao write -f auth/approle/role/external-secrets/secret-id

You’ll get values like:

  • role_id = 12345678-aaaa-bbbb-cccc-1234567890ab
  • secret_id = abcdef12-3456-7890-abcd-ef1234567890

External Secrets Operator

SecretStore

The first step is to tell the External Secrets Operator how to connect to OpenBao. You can do this with a SecretStore (namespaced) or a ClusterSecretStore (cluster-wide).

Here’s a simple example:

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: openbao-vault
spec:
  provider:
    vault:
      server: "http://openbao.default.svc.cluster.local:8200"
      path: "test"
      version: "v2"
      auth:
        appRole:
          path: "approle"
          roleId: "12345678-aaaa-bbbb-cccc-1234567890ab"
          secretRef:
            name: "openbao-approle"
            key: "secretId"
            namespace: "external-secrets"

And for the secret ID:

apiVersion: v1
kind: Secret
metadata:
  name: openbao-approle
  namespace: external-secrets
type: Opaque
stringData:
  secretId: abcdef12-3456-7890-abcd-ef1234567890

ExternalSecret

Now you can create an ExternalSecret that fetches the KV values you stored earlier in OpenBao and turns them into a regular Kubernetes secret.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: test
  namespace: default
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: openbao-vault
    kind: ClusterSecretStore
  target:
    name: test-app-env
  data:
    - secretKey: SERVER_NAME # this will be the key in your secret
      remoteRef:
        key: test # this is the secret in openbao
        property: SERVER_NAME # this is a key in your secret
    - secretKey: TEST
      remoteRef:
        key: test
        property: TEST

Reloader

By default, when a Kubernetes Secret or ConfigMap changes, the pods that consume it are not restarted automatically.

Reloader watches for changes to secrets and configmaps, and whenever it detects an update, it triggers a rolling restart of the affected deployments. This ensures that applications always pick up the latest configuration or credentials without manual intervention.

apiVersion: v1
kind: Namespace
metadata:
  name: reloader
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: reloader
spec:
  interval: 24h
  url: https://stakater.github.io/stakater-charts
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: reloader
spec:
  interval: 10m
  chart:
    spec:
      chart: reloader
      version: 2.2.3
      sourceRef:
        kind: HelmRepository
        name: reloader
        namespace: reloader
      interval: 10m

Deploy a test application

I tested this configuration of OpenBao and ExternalSecrets with a simple frankenphp deployment :

apiVersion: apps/v1
kind: Deployment
metadata:
  name: frankenphp
  namespace: default
  annotations:
    reloader.stakater.com/auto: true # Important !!!
spec:
  replicas: 1
  selector:
    matchLabels:
      app: frankenphp
  template:
    metadata:
      labels:
        app: frankenphp
    spec:
      containers:
        - name: frankenphp
          image: dunglas/frankenphp:alpine
          ports:
            - containerPort: 80
          envFrom:
            - secretRef:
                name: test-app-env
---
apiVersion: v1
kind: Service
metadata:
  name: frankenphp
  namespace: default
spec:
  selector:
    app: frankenphp
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: frankenphp
  namespace: default
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`frankenphp.example.com`)
      kind: Rule
      services:
        - name: frankenphp
          namespace: default
          port: 80

If you visit the URL of the deployed frankenphp, you see a phpinfo():

frankenphp environment

Conclusion

This little homelab project showed me how well OpenBao, External Secrets Operator, and Reloader complement each other. OpenBao keeps secrets safe, ESO bridges them into Kubernetes, and Reloader ensures applications always run with the latest values.

It’s a setup that not only makes secret rotation effortless at home, but also feels solid enough for real-world production use. I’d be confident running the same pattern in a professional environment, especially alongside GitOps with FluxCD.