Skip to main content

External Secrets Operator Integration

Arcan provides an ESO-compatible webhook endpoint that lets the External Secrets Operator sync secrets from Arcan into Kubernetes Secrets automatically.

Why Webhook Provider?

ESO supports many providers natively (AWS SM, Vault, GCP SM), but Arcan uses the webhook provider -- a generic HTTP-based backend. This means:

  • No custom ESO controller or CRD required
  • Works with any ESO installation out of the box
  • Arcan controls the API contract (/api/v1/eso/...)

Prerequisites

  1. External Secrets Operator installed in your cluster (install guide)
  2. Arcan server running and reachable from the cluster (e.g., https://arcan.internal:8443)
  3. Arcan API token created:
    arcan token create --name="eso-operator" --scopes read

Quick Start

Step 1: Create the token Secret

kubectl create secret generic arcan-credentials \
--namespace=default \
--from-literal=token=arc_YOUR_API_TOKEN

Step 2: Trust Arcan's TLS certificate

If Arcan uses a self-signed or private CA certificate:

kubectl create configmap arcan-ca \
--namespace=default \
--from-file=ca.crt=/path/to/arcan-ca.crt

Skip this if Arcan uses a publicly trusted certificate (and remove the caProvider block from the manifests).

Step 3: Generate and apply manifests

arcan generate eso --realm=production --env=prod --namespace=default | kubectl apply -f -

Or use the static manifests in deploy/kubernetes/eso/ after replacing the REPLACE_ME placeholders:

kubectl apply -f deploy/kubernetes/eso/arcan-token-secret.yaml
kubectl apply -f deploy/kubernetes/eso/secret-store.yaml
kubectl apply -f deploy/kubernetes/eso/external-secret.yaml

API Endpoints

EndpointMethodResponseUse Case
/api/v1/eso/{realm}/{key}?env=prodGET{"value": "secret-value"}Single secret
/api/v1/eso/{realm}?env=prodGET{"data": {"KEY1": "val1", ...}}All secrets in realm

Both endpoints require Authorization: Bearer arc_... header.

Bulk Secrets

To sync all secrets from a realm at once, use the bulk endpoint with a separate SecretStore:

kubectl apply -f deploy/kubernetes/eso/external-secret-bulk.yaml

The bulk SecretStore points to /api/v1/eso/{realm}?env={env} (no key in the path) and uses $.data as the jsonPath to extract all key-value pairs.

ClusterSecretStore

For cross-namespace access, use a ClusterSecretStore:

arcan generate eso --realm=production --env=prod --cluster-wide | kubectl apply -f -

Or apply the static manifest:

kubectl apply -f deploy/kubernetes/eso/cluster-secret-store.yaml

With a ClusterSecretStore, ExternalSecrets in any namespace can reference kind: ClusterSecretStore in their secretStoreRef. The token Secret and CA ConfigMap must exist in the ESO controller namespace (typically external-secrets).

TLS Trust

Arcan requires TLS in all deployment modes. If using a self-signed certificate (the default for standalone mode), ESO needs to trust Arcan's CA.

The caProvider block in the SecretStore tells ESO where to find the CA:

caProvider:
type: ConfigMap
name: arcan-ca
key: ca.crt

To extract Arcan's CA certificate:

# Run `arcan doctor` to find the CA certificate path, then copy it:
cp <ca-cert-path> ./arcan-ca.crt

# Or fetch it from a running server
openssl s_client -connect arcan.internal:8443 -showcerts </dev/null 2>/dev/null \
| openssl x509 -outform PEM > arcan-ca.crt

Refresh Intervals

The refreshInterval on an ExternalSecret controls how often ESO polls Arcan:

IntervalPropagation delayAPI load
15mUp to 15 minutesLow
1h (default)Up to 1 hourVery low
5mUp to 5 minutesModerate
1mUp to 1 minuteHigh

Choose based on how quickly secret changes need to propagate. For most workloads, 1h is sufficient.

Troubleshooting

ExternalSecret stuck in "SecretSyncedError"

Check the ESO controller logs:

kubectl logs -n external-secrets deploy/external-secrets -f

Common causes:

  • Connection refused: Arcan not reachable. Verify the URL and that the Arcan service exists in the cluster.
  • TLS handshake failure: CA certificate not trusted. Ensure the arcan-ca ConfigMap exists and contains the correct certificate.
  • 401 Unauthorized: Invalid or expired token. Verify the token in the arcan-credentials Secret.
  • 404 Not Found: Wrong realm slug or key name. Check the realm exists with arcan realm list.

Verify connectivity from the cluster

kubectl run arcan-test --rm -it --image=curlimages/curl -- \
curl -sk -H "Authorization: Bearer arc_YOUR_TOKEN" \
https://arcan.internal:8443/api/v1/eso/production/DATABASE_URL?env=prod

Check SecretStore status

kubectl get secretstore arcan -o yaml
kubectl describe secretstore arcan

A healthy SecretStore shows status.conditions with type: Ready and status: "True".

Check ExternalSecret sync status

kubectl get externalsecret my-app-secrets -o yaml
kubectl describe externalsecret my-app-secrets

Look at status.conditions and status.syncedResourceVersion to confirm secrets are syncing.

Complete End-to-End Example

Walk through the full flow from creating an Arcan token to verifying the Kubernetes Secret exists.

1. Create an Arcan token for ESO

arcan token create --name="k8s-eso-operator" --scopes read

# Output:
# Token: arc_eso_a1b2c3d4e5f6g7h8i9j0
# Scopes: read
# Save this token — it won't be shown again.

2. Store the token in Kubernetes

kubectl create secret generic arcan-credentials \
--namespace=default \
--from-literal=token=arc_eso_a1b2c3d4e5f6g7h8i9j0

3. Apply the SecretStore

# arcan-secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: arcan
namespace: default
spec:
provider:
webhook:
url: "https://arcan.internal:8443/api/v1/eso/production/{{ .remoteRef.key }}?env=prod"
headers:
Authorization: "Bearer {{ .authRef.SecretAccessKey }}"
Content-Type: application/json
result:
jsonPath: "$.value"
secrets:
- name: SecretAccessKey
secretRef:
name: arcan-credentials
key: token
caProvider:
type: ConfigMap
name: arcan-ca
key: ca.crt
kubectl apply -f arcan-secret-store.yaml

4. Apply the ExternalSecret

# app-secrets.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: myapp-secrets
namespace: default
spec:
refreshInterval: 15m
secretStoreRef:
name: arcan
kind: SecretStore
target:
name: myapp-secrets # Name of the K8s Secret that will be created
creationPolicy: Owner # ESO owns and manages this Secret
data:
- secretKey: DATABASE_URL # Key in the K8s Secret
remoteRef:
key: DATABASE_URL # Key in Arcan
- secretKey: REDIS_URL
remoteRef:
key: REDIS_URL
- secretKey: STRIPE_SECRET
remoteRef:
key: STRIPE_SECRET
kubectl apply -f app-secrets.yaml

5. Verify the Kubernetes Secret was created

# Check ExternalSecret status
kubectl get externalsecret myapp-secrets
# NAME STORE REFRESH INTERVAL STATUS
# myapp-secrets arcan 15m SecretSynced

# Verify the K8s Secret exists
kubectl get secret myapp-secrets
# NAME TYPE DATA AGE
# myapp-secrets Opaque 3 12s

# Confirm the keys are present (values are base64 encoded)
kubectl get secret myapp-secrets -o jsonpath='{.data}' | jq .
# {
# "DATABASE_URL": "cG9zdGdyZXM6Ly91c2VyOnBhc3NAZGIuZXhhbXBsZS5jb206NTQzMi9teWFwcA==",
# "REDIS_URL": "cmVkaXM6Ly9jYWNoZS5leGFtcGxlLmNvbTo2Mzc5",
# "STRIPE_SECRET": "c2tfbGl2ZV9hYmMxMjNkZWY0NTY="
# }

6. Use the Secret in a Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: default
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: myapp:latest
envFrom:
- secretRef:
name: myapp-secrets # All keys injected as env vars

Syncing All Secrets from a Realm (dataFrom)

Instead of listing each secret individually, you can sync every secret in a realm at once using the bulk endpoint and dataFrom:

# bulk-secrets.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: arcan-bulk
namespace: default
spec:
provider:
webhook:
url: "https://arcan.internal:8443/api/v1/eso/production?env=prod"
headers:
Authorization: "Bearer {{ .authRef.SecretAccessKey }}"
Content-Type: application/json
result:
jsonPath: "$.data"
secrets:
- name: SecretAccessKey
secretRef:
name: arcan-credentials
key: token
caProvider:
type: ConfigMap
name: arcan-ca
key: ca.crt
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: myapp-all-secrets
namespace: default
spec:
refreshInterval: 15m
secretStoreRef:
name: arcan-bulk
kind: SecretStore
target:
name: myapp-all-secrets
creationPolicy: Owner
dataFrom:
- extract:
key: "all" # The bulk endpoint returns all keys
kubectl apply -f bulk-secrets.yaml

# All secrets from the production realm are now in a single K8s Secret
kubectl get secret myapp-all-secrets -o jsonpath='{.data}' | jq 'keys'
# ["API_KEY", "DATABASE_URL", "REDIS_URL", "SENTRY_DSN", "STRIPE_SECRET"]
tip

Use dataFrom for microservices that need every secret in a realm. Use individual data entries when you want to cherry-pick specific secrets or map Arcan keys to different Kubernetes Secret keys.

Quick Reference: End-to-End in 5 Commands

If you want the fastest path from zero to a working Kubernetes Secret synced from Arcan:

# 1. Create a read-only token in Arcan
arcan token create --name="k8s-eso" --scopes read
# Output: arc_eso_a1b2c3d4e5f6g7h8i9j0

# 2. Store the token as a Kubernetes Secret
kubectl create secret generic arcan-credentials \
--namespace=default \
--from-literal=token=arc_eso_a1b2c3d4e5f6g7h8i9j0

# 3. Store the Arcan CA certificate (skip if using a publicly trusted cert)
kubectl create configmap arcan-ca \
--namespace=default \
--from-file=ca.crt=<ca-cert-path>

# 4. Generate and apply SecretStore + ExternalSecret manifests
arcan generate eso --realm=production --env=prod --namespace=default | kubectl apply -f -

# 5. Verify the Kubernetes Secret was created
kubectl get secret myapp-secrets -o jsonpath='{.data}' | jq 'keys'
# ["API_KEY", "DATABASE_URL", "REDIS_URL", "SENTRY_DSN", "STRIPE_SECRET"]

That is it. ESO will now poll Arcan every hour (default) and keep the Kubernetes Secret in sync. To use the secret in a pod:

apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
spec:
containers:
- name: myapp
image: myapp:latest
envFrom:
- secretRef:
name: myapp-secrets

Common ESO Status Conditions

When debugging, check kubectl describe externalsecret <name>. Here are the status conditions and what they mean:

ConditionStatusMeaningFix
ReadyTrueSecrets are synced and up to dateEverything is working
ReadyFalse, reason SecretSyncedErrorESO cannot fetch from ArcanCheck connectivity, token, and realm name
ReadyFalse, reason SecretStoreNotReadyThe referenced SecretStore is not healthyRun kubectl describe secretstore arcan
ReadyFalse, reason SecretDeletedThe target K8s Secret was manually deletedESO will recreate it on the next refresh cycle
ReadyFalse, reason InvalidStoreRefThe SecretStore name or kind is wrongVerify secretStoreRef matches your SecretStore
ReadyFalse, reason ProviderErrorThe webhook returned a non-200 statusCheck Arcan server logs and token validity

To see the exact error message:

kubectl get externalsecret myapp-secrets -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}'