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

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():

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.