Infrastructure

Secrets Management

1Password-based secret management for local development, CI/CD, and Kubernetes

Philosophy

1Password is the single source of truth for all secrets across all environments. Secrets never exist as plaintext in the repository, local disk, or CI logs.

LayerMechanismSecrets on Disk?
Local development.env.op files with op:// references, resolved by op runNo
CI/CDGitHub Actions secrets injected at runtimeNo
Kubernetes1Password Operator syncs vault items to K8s SecretsEncrypted at rest (etcd)

Local Development

.env.op Files

Each app has an .env.op file (committed to git) containing op:// URI references alongside literal defaults:

# Example: apps/lexilink/.env.op
STRIPE_SECRET_KEY=op://k3/lexilink-prod/STRIPE_SECRET_KEY  # Resolved by op run
NEXT_PUBLIC_APP_URL=http://localhost:3000                    # Passed through literally
  • op:// references are resolved at runtime by op run — secrets never touch disk
  • Literal values pass through unchanged (non-secret dev defaults)
  • Lexilink has additional .env.op.staging and .env.op.preview files for multi-environment support

Setup: OP_SERVICE_ACCOUNT_TOKEN

All dev:* scripts require a 1Password Service Account token:

# Add to ~/.zshrc (get token from 1Password admin)
export OP_SERVICE_ACCOUNT_TOKEN="ops_..."
source ~/.zshrc

Running with Secrets

The scripts/op-dev.sh script wraps op run for ephemeral secret resolution:

bun dev:lexilink                   # Dev with prod secrets
bun dev:lexilink:staging           # Dev with staging secrets
bun dev:lexilink:preview           # Dev with preview secrets
bun dev:calnexus                   # Dev calnexus with secrets
bun dev:planex                     # Dev planex with secrets
bun dev:archus                     # Dev archus with secrets

Each dev:* script runs: op run --env-file apps/<app>/.env.op -- turbo dev --filter=<app>

Regenerating .env.op Files

The scripts/generate-env-op.sh script regenerates .env.op files from 1Password item fields:

bun env:generate                                           # Regenerate all .env.op files
./scripts/generate-env-op.sh lexilink --env staging        # Regenerate one app/env

Runtime Validation with t3-env

All apps use t3-env (via @hn-monorepo/config) for runtime environment variable validation. The configuration uses emptyStringAsUndefined: true to catch blank values.

During Docker builds, CI=true is set in the builder stage to skip env validation at build time (secrets are not available during build).

CI/CD Secrets

GitHub Actions secrets are injected into workflow runs at runtime.

SecretUsed ByPurpose
HARBOR_USERNAMEdeploy.ymlHarbor registry login
HARBOR_PASSWORDdeploy.ymlHarbor registry login
KUBECONFIGdeploy.ymlBase64-encoded kubeconfig for kubectl access
CONVEX_ADMIN_KEY_<APP>deploy.ymlPer-app Convex admin keys (7 apps)
CONVEX_ADMIN_KEY_<APP>_STAGINGdeploy.ymlStaging Convex admin keys
CONVEX_ADMIN_KEY_<APP>_PREVIEWdeploy.ymlPreview Convex admin keys
TURBO_TOKENci.ymlTurborepo remote cache token
TURBO_TEAMci.ymlTurborepo team identifier
OP_SERVICE_ACCOUNT_TOKENdeploy.yml1Password CLI access (if needed)

Kubernetes Secrets (1Password Operator)

How It Works

The 1Password Connect Operator runs in op-system and watches for OnePasswordItem custom resources. Each app has a onepassworditem.yaml that maps a 1Password vault item to a Kubernetes Secret:

apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
  name: lexilink-secrets
  namespace: hn-apps
spec:
  itemPath: "vaults/k3/items/lexilink-prod"

The operator creates a Kubernetes Secret named lexilink-secrets with fields from the vault item. Deployments reference these fields via secretKeyRef:

env:
  - name: STRIPE_SECRET_KEY
    valueFrom:
      secretKeyRef:
        name: lexilink-secrets
        key: STRIPE_SECRET_KEY

Auto-Restart on Secret Change

Deployments use the auto-restart annotation to ensure pods restart when the underlying 1Password item changes:

annotations:
  operator.1password.io/auto-restart: "true"

Important: Field Name Casing

The 1Password Operator preserves original casing from vault items. It does NOT lowercase field names. Ensure vault item fields match exactly what the application expects (typically SCREAMING_SNAKE_CASE).

Vault Structure

All secrets are stored in the k3 vault in 1Password. The naming convention is:

Item NameEnvironmentUsed By
<app>-prodProductionhn-apps namespace
<app>-stagingStaginghn-staging namespace
<app>-previewPreviewhn-preview namespace
convex-<app>-prodProductionconvex namespace
convex-<app>-stagingStaginghn-staging namespace
convex-<app>-previewPreviewhn-preview namespace

Special Cases

  • harbor-registry secret is manually managed (the .dockerconfigjson key format is incompatible with the 1Password Operator)
  • auth-secret is shared across namespaces via k8s/secrets/
  • Vault item titles with em-dashes can break op read — use op item get <id> instead

Multi-Environment Support

Currently only lexilink has full multi-environment support (production, staging, preview). Other apps default to prod only.

EnvironmentBranch/TriggerNamespace1Password Item
ProductionPush to masterhn-apps / convexlexilink-prod
StagingPush to staginghn-staginglexilink-staging
PreviewPR label deploy:previewhn-previewlexilink-preview

To add multi-environment support for another app, create the corresponding vault items in 1Password and follow the overlay pattern described in the Deployment page.

Troubleshooting

1Password secrets not syncing to Kubernetes

  1. Check operator logs: kubectl logs -n op-system -l app=onepassword-connect
  2. Verify the vault item exists: op item get <app>-prod --vault k3
  3. Ensure field names use the expected casing (AUTH_SECRET, not auth_secret)
  4. Check the OnePasswordItem resource status: kubectl describe onepassworditem -n hn-apps

Local dev: “op: error initializing client”

  • Verify OP_SERVICE_ACCOUNT_TOKEN is set: echo $OP_SERVICE_ACCOUNT_TOKEN
  • Token may have expired — request a new one from the 1Password admin
  • Ensure op CLI is installed: brew install 1password-cli

Missing env var in Kubernetes pod

  1. Check the Deployment manifest references the correct secret name and key
  2. Verify the field exists in the 1Password vault item
  3. Check the Secret was created: kubectl get secret <app>-secrets -n hn-apps -o yaml
  4. Look for typos in field name casing
HanseNexus 2026