Secrets Management
Configure secrets for your self-hosted Align deployment.
Required Secrets
Align requires these secrets to function:
| Secret | Purpose |
|---|---|
| Database credentials | PostgreSQL connection |
| Internal secrets | JWT, cookies, encryption |
| LLM API keys | OpenAI/Anthropic (or custom) |
Each OAuth-backed connector you enable needs credentials, your own, from apps you register. Without them the connector is reported as not configured and its authorization flow is refused. An OAuth app uses a client id and secret; a GitHub App uses an app id and private key; the align connector is auth: none and needs neither. Either way your gateway talks directly to the provider. See Connector Authentication.
Relay connector tokens
The internal secret can carry three more keys: service-auth-token-slack, service-auth-token-teams and service-auth-token-github. Each is a long random string (the chart generates 64 characters), different from service-auth-token and from each other.
They prove which connector is calling. The gateway records a person's conflict verdict relayed by one of these connectors as a human's, and it hands each connector its tenant's credentials only when the connector presents its own key.
The Slack and Teams keys are required if you run those connectors. The gateway answers GET /integrations/slack/credentials and GET /integrations/teams/credentials only to that connector presenting its own key, and only for a tenant with an active connection to it. Without the key, the connector falls back to service-auth-token, the gateway answers 403, and the connector cannot read its tenant's credentials. The gateway logs a warning at startup naming SERVICE_AUTH_TOKEN_SLACK or SERVICE_AUTH_TOKEN_TEAMS when it holds that connector's OAuth client id but no usable key. While the gateway is rolling, a gateway pod that started before a key existed rejects it with a 401, and the connector retries that call once with service-auth-token, which is then refused.
The other connectors (Jira, Confluence, Zoom, Linear, Azure DevOps, Microsoft 365 documents) still read credentials with service-auth-token, and only for a tenant with an active connection to that connector.
The GitHub key is required if you use a GitHub App. The gateway hands GitHub App credentials (GET /integrations/github/app-credentials) only to the GitHub connector presenting service-auth-token-github, and only for a tenant with an active GitHub connection. Without the key, the GitHub connector falls back to service-auth-token, the gateway answers 403, and every tenant-scoped GitHub call fails. The gateway logs a warning at startup naming SERVICE_AUTH_TOKEN_GITHUB when it holds a GitHub App key but no usable GitHub token. The same happens if the value equals service-auth-token or another relay token.
github-inbound-token is required if you run the GitHub connector. It is the only credential the GitHub connector's /internal/* routes accept, and only the gateway and the GitHub connector hold it. Both pods require the key, so a missing key stops them starting. Use a long random string, different from service-auth-token and every relay token. If you run the connector outside the chart, set GITHUB_INBOUND_TOKEN on both services. Unset, those routes answer 401 and the connector logs GITHUB_INBOUND_TOKEN is not set.
With secrets.create: true the chart generates the relay keys and github-inbound-token. With External Secrets, each flag syncs its own properties, and the sync fails on a property that does not exist, so create the properties in <secretPath>/internal first, then set the flag:
| Flag | Properties it syncs |
|---|---|
externalSecrets.relayServiceTokens: true | service-auth-token-slack, service-auth-token-teams, service-auth-token-github |
externalSecrets.githubInboundToken: true | github-inbound-token |
Hosted MCP server token (needed for its connect tool)
If you run the hosted MCP server (connectors.align.enabled: true), the internal secret needs one more key: service-auth-token-align. It is a long random string, different from service-auth-token and from the relay tokens.
The MCP server's connect tool starts a connector's OAuth flow through POST /integrations/:key/enable. The gateway accepts only this token for that call, never service-auth-token, because every connector holds the shared token and it names its own tenant.
If the key is absent, the MCP server falls back to service-auth-token. Every other tool keeps working, but connect answers 403, and the gateway logs a warning at startup naming SERVICE_AUTH_TOKEN_ALIGN. This is deliberate: a missing key closes the route rather than opening it.
If the key is present but the gateway cannot use it, the MCP server does not fall back, and every tool answers 401. That happens when the value equals another relay token (the gateway ignores both), and while a gateway pod that started before the key existed is still serving.
With secrets.create: true the chart generates it. With External Secrets, add the property service-auth-token-align to <secretPath>/internal first, then set externalSecrets.alignRelayServiceToken: true. It has its own flag so an install that already set relayServiceTokens keeps syncing. If you manage the Secret yourself, add the key, then restart the gateway, then the MCP server: a pod reads the key only when it starts.
Connector tokens (optional)
Seven connectors can each carry their own key in the internal secret: service-auth-token-jira, service-auth-token-confluence, service-auth-token-zoom, service-auth-token-linear, service-auth-token-notion, service-auth-token-azure-devops and service-auth-token-m365-documents. Each is a long random string, different from service-auth-token, from the relay tokens and from each other. The Google Drive connector can carry an eighth, service-auth-token-google-drive, the same way. It is not part of externalSecrets.connectorRelayServiceTokens, so an install that already set that flag keeps syncing; add the property yourself if you want Drive on its own key.
They let the gateway tell which connector is calling, rather than seeing one shared token that every connector holds. Nothing is refused because of them today.
A missing key breaks nothing. That connector calls the gateway with service-auth-token, exactly as before, and the gateway logs one warning at startup naming every unset SERVICE_AUTH_TOKEN_<NAME>. The same holds while the gateway is rolling: a gateway pod that started before a key existed rejects it with a 401, and the connector retries that call once with service-auth-token. A value equal to service-auth-token or to another relay token is ignored the same way.
With secrets.create: true the chart generates them. With External Secrets, add all seven properties to <secretPath>/internal first, then set externalSecrets.connectorRelayServiceTokens: true. It has its own flag, so an install that already set the other two keeps syncing. If you manage the Secret yourself, add the keys, then restart the gateway, then the connectors.
Secret Management Options
Choose the approach that fits your infrastructure:
| Method | Best For |
|---|---|
| SOPS | GitOps workflows, encrypted at rest |
| Sealed Secrets | Kubernetes-native, cluster-specific encryption |
| External Secrets | AWS/GCP/Azure secret managers |
| Vault | HashiCorp Vault users |
| Manual | Simple deployments, testing |
Option 1: SOPS (Recommended for GitOps)
SOPS encrypts secrets in Git while keeping them decryptable in your cluster.
Setup
- Install SOPS and configure your KMS key:
# Install SOPS
brew install sops # macOS
# or
apt-get install sops # Linux
# Configure KMS (example: AWS KMS)
export SOPS_KMS_ARN="arn:aws:kms:region:account:key/key-id"
- Create a
.sops.yamlconfiguration:
# .sops.yaml
creation_rules:
- path_regex: secrets/.*\.yaml$
kms: arn:aws:kms:eu-west-2:123456789:key/your-key-id
- Create and encrypt secrets:
# Create plaintext secret file
cat > secrets/align-secrets.yaml << 'EOF'
apiVersion: v1
kind: Secret
metadata:
name: align-internal
namespace: align
type: Opaque
stringData:
jwt-secret: "your-jwt-secret-here"
cookie-secret: "your-cookie-secret-here"
encryption-key: "your-encryption-key-here"
master-encryption-key: "your-master-encryption-key-here"
service-auth-token: "your-service-auth-token"
EOF
# Encrypt with SOPS
sops -e -i secrets/align-secrets.yaml
# File is now encrypted, safe to commit
git add secrets/align-secrets.yaml
- Decrypt and apply (in CI/CD or manually):
sops -d secrets/align-secrets.yaml | kubectl apply -f -
With ArgoCD
Use the KSOPS plugin:
# kustomization.yaml
generators:
- ./secret-generator.yaml
# secret-generator.yaml
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
name: secret-generator
files:
- ./secrets/align-secrets.enc.yaml
With Flux
Use the SOPS integration:
# kustomization.yaml (Flux)
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: align
spec:
decryption:
provider: sops
secretRef:
name: sops-gpg
Option 2: Sealed Secrets
Bitnami Sealed Secrets encrypts secrets with a cluster-specific key.
Setup
- Install Sealed Secrets controller:
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
--namespace kube-system
- Install kubeseal CLI:
brew install kubeseal # macOS
- Create and seal secrets:
# Create plaintext secret (don't commit this!)
cat > /tmp/secret.yaml << 'EOF'
apiVersion: v1
kind: Secret
metadata:
name: align-internal
namespace: align
stringData:
jwt-secret: "your-jwt-secret"
cookie-secret: "your-cookie-secret"
encryption-key: "your-encryption-key"
master-encryption-key: "your-master-encryption-key"
service-auth-token: "your-service-auth-token"
EOF
# Seal it
kubeseal -o yaml < /tmp/secret.yaml > sealedsecrets/align-internal.yaml
# Delete plaintext
rm /tmp/secret.yaml
# Safe to commit sealed version
git add sealedsecrets/align-internal.yaml
- Apply sealed secret:
kubectl apply -f sealedsecrets/align-internal.yaml
Sealed Secrets are cluster-specific. If you recreate your cluster, you'll need to re-seal all secrets with the new controller key.
Option 3: External Secrets Operator
Pull secrets from AWS Secrets Manager, GCP Secret Manager, or Azure Key Vault.
Setup (AWS)
- Install External Secrets Operator:
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
--namespace external-secrets \
--create-namespace
- Create a ClusterSecretStore:
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: aws-secrets-manager
spec:
provider:
aws:
service: SecretsManager
region: eu-west-2
auth:
jwt:
serviceAccountRef:
name: external-secrets
namespace: external-secrets
- Create ExternalSecret for Align:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: align-internal
namespace: align
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: aws-secrets-manager
target:
name: align-internal
data:
- secretKey: jwt-secret
remoteRef:
key: align/production
property: jwt-secret
- secretKey: cookie-secret
remoteRef:
key: align/production
property: cookie-secret
- secretKey: encryption-key
remoteRef:
key: align/production
property: encryption-key
- secretKey: master-encryption-key
remoteRef:
key: align/production
property: master-encryption-key
- secretKey: service-auth-token
remoteRef:
key: align/production
property: service-auth-token
- Enable in Helm values:
externalSecrets:
enabled: true
secretStore:
name: aws-secrets-manager
kind: ClusterSecretStore
aws:
secretPath: "align/production"
Option 4: HashiCorp Vault
For organizations using Vault.
Setup
-
Install Vault Agent Injector or External Secrets Vault provider
-
Create Vault secrets:
vault kv put secret/align/production \
jwt-secret="your-jwt-secret" \
cookie-secret="your-cookie-secret" \
encryption-key="your-encryption-key" \
master-encryption-key="your-master-encryption-key" \
service-auth-token="your-service-auth-token"
- Configure External Secrets for Vault:
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault
spec:
provider:
vault:
server: "https://vault.example.com"
path: "secret"
auth:
kubernetes:
mountPath: "kubernetes"
role: "align"
Option 5: Manual Secrets
For simple deployments or testing.
# Generate random secrets
JWT_SECRET=$(openssl rand -base64 32)
COOKIE_SECRET=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -base64 32)
MASTER_ENCRYPTION_KEY=$(openssl rand -base64 32)
SERVICE_TOKEN=$(openssl rand -base64 32)
# Create Kubernetes secret
kubectl create secret generic align-internal \
--namespace align \
--from-literal=jwt-secret="$JWT_SECRET" \
--from-literal=cookie-secret="$COOKIE_SECRET" \
--from-literal=encryption-key="$ENCRYPTION_KEY" \
--from-literal=master-encryption-key="$MASTER_ENCRYPTION_KEY" \
--from-literal=service-auth-token="$SERVICE_TOKEN"
Manual secrets are not GitOps-friendly and can be lost if the cluster is recreated. Use for development only.
Secret Reference
align-database
stringData:
url: "postgresql://user:pass@host:5432/align"
host: "your-db-host.example.com"
port: "5432"
username: "align"
password: "your-db-password"
align-internal
stringData:
jwt-secret: "32+ character random string"
cookie-secret: "32+ character random string"
encryption-key: "32+ character random string"
master-encryption-key: "base64 or hex of 32 random bytes (openssl rand -base64 32)" # -> ALIGN_MASTER_ENCRYPTION_KEY (required)
# previous-encryption-keys: "old-key-1,old-key-2" # -> ALIGN_PREVIOUS_ENCRYPTION_KEYS (optional; only during a key rotation - see key-rotation.md)
service-auth-token: "32+ character random string"
master-encryption-key (exposed to the gateway as ALIGN_MASTER_ENCRYPTION_KEY,
32 bytes, base64 or hex) is mandatory. OAuth tokens and connector API keys are
encrypted at rest with it. The gateway refuses to start if it is missing - it will
not silently store tokens in plaintext. For local development only, you may set
ALIGN_ALLOW_PLAINTEXT_TOKENS=true to bypass encryption; never do this in production.
(Note: the separate encryption-key secret value is wired to a different,
gateway-unused env var - the key that gates token encryption is master-encryption-key.)
align-llm
stringData:
openai-api-key: "sk-..."
# OR
anthropic-api-key: "sk-ant-..."
# OR for custom providers
custom-base-url: "http://ollama:11434/v1"
custom-model: "llama3:70b"
The brain also reads azure-embedding-api-key and embedding-api-key when you choose those embedding
profiles. If you would rather create this Secret under a different name, set secrets.llm.existingSecret to
that name and give it the same keys. The chart then renders no <release>-llm Secret of its own. See
Keep the key out of your values file.
align-posthog (hosted only, do not create on a self-host)
Align's own hosted environments use this secret for the gateway's PostHog analytics bridge. A
self-hosted install does not create it. The chart renders no POSTHOG_* variable on the gateway
unless ui.analytics.enabled is true, and that value is false by default, so a self-host sends
nothing to PostHog. Product analytics for your install stay in your own telemetry_events table.
Best Practices
- Never commit plaintext secrets - Always encrypt or use external secret managers
- Rotate secrets regularly - Especially after team member departures
- Use separate secrets per environment - Don't share secrets between prod and staging
- Audit access - Use cloud provider audit logs for secret access
- Backup encryption keys - Store SOPS/KMS keys securely outside the cluster