Skip to content

Bitwarden Secrets Manager Setup

This repo loads all secrets and configuration from Bitwarden Secrets Manager at container start, instead of a plaintext .env. The only value stored locally is a Bitwarden access token.

Why

  • Secret values never sit in a file on disk. The gitignored .env holds just the access token.
  • One central store, rotatable and revocable, shared across machines.
  • bws run injects each secret as an environment variable named exactly as the secret, so Terraform (TF_VAR_*, TF_TOKEN_app_terraform_io) and Ansible (lookup('env', ...)) work unchanged.

How it works

When you enter the container (task docker:exec), scripts/container/root/.bashrc sources the loader scripts/container/root/bws-load.sh, which:

  1. Reads BWS_ACCESS_TOKEN (+ BWS_PROJECT_ID) from the mounted /srv/.env.
  2. Re-runs bash under bws run --project-id <id> -- bash, which authenticates to Bitwarden cloud and injects every secret in the project as a native environment variable.
  3. A BWS_LOADED guard prevents the re-run from recursing. Inside the injected shell the access token is unset, so it is not inherited by terraform/ansible or any subprocess.

If Bitwarden is unreachable or the token is bad, you still get a plain (secret-less) shell with a warning, not a lockout. BWS_SKIP=1 bypasses the auto-load entirely.

Reload without restarting. After you add or change a secret in Bitwarden, refresh the current shell in place, no need to exit the container:

bws-load        # clears the guard and re-execs bash, pulling the latest secrets

For a one-off command with secrets injected (host or CI, non-interactive), wrap it with bws run:

bws run --project-id <id> -- terraform -chdir=stage2 plan

Important: Bitwarden cloud is required. Vaultwarden does not implement Secrets Manager. The free tier covers this repo (unlimited secrets, 3 projects, 3 machine accounts).

1. Install the bws CLI locally

You need bws on your machine to create and manage secrets. There is no Homebrew formula; use the official install script (it detects platform/arch, downloads, and validates the checksum):

curl -fsSL https://bws.bitwarden.com/install | sh
bws --version                            # -> bws 2.1.0

Pin a version with BWS_VERSION=2.1.0 curl -fsSL https://bws.bitwarden.com/install | sh. Or download a binary directly from the releases page (macOS: bws-macos-universal-*.zip; Linux: bws-<arch>-unknown-linux-musl-*.zip) and put it on PATH.

(The container already has bws baked into its image, so you only need it locally for management.)

2. Provision the backend (one-time)

The project homelab-infrastructure already exists. To (re)create the pieces on a fresh org:

  1. Enable Secrets Manager on your Bitwarden organization.
  2. Create a project, e.g. homelab-infrastructure. Find its id with bws project list.
  3. Create a machine account and grant it can read on the project.
  4. Create an access token for that machine account. Treat it like a password.

3. Configure your local .env

Copy the template and fill in only the two Bitwarden values (get the project id from bws project list):

cp .env.example .env
# then edit .env:
#   BWS_ACCESS_TOKEN=<your machine-account access token>
#   BWS_PROJECT_ID=<id from `bws project list`>

.env is gitignored. Neither value is ever committed.

Optional hardening (no token in any file): leave BWS_ACCESS_TOKEN out of .env, keep it in your host keychain/pass, and pass it in at run time with docker run -e BWS_ACCESS_TOKEN ....

4. Create the secrets

Load your ids first so the project id is never typed inline:

set -a; . .env; set +a

Web UI: Secrets Manager → project homelab-infrastructure → New secret. Name = the env var, Value = the value, assign to the project.

CLI:

bws secret create <NAME> "<VALUE>" "$BWS_PROJECT_ID"

# examples:
bws secret create k3s_token "$(openssl rand -base64 64 | tr -d '\n')" "$BWS_PROJECT_ID"
bws secret create TF_VAR_prometheus_grafana_domain "grafana.chrislee.local" "$BWS_PROJECT_ID"

Store literal values. Bitwarden does not expand ${...}. Resolve any interpolation before saving, e.g. grafana.${domain_host} → grafana.chrislee.local, ${server_ssh_host} → 192.168.1.100.

JSON values (worker_hosts_json, etc_hosts_json, worker_default_taints) are stored raw, with no \" escaping. bws run injects them natively, which is why the old godotenv escaping rule is gone.

Never name a secret after a shell-sensitive variable (PATH, LD_PRELOAD, LD_LIBRARY_PATH, BASH_ENV, ENV, IFS). These are injected into the shell and can enable code execution. Only the documented TF_VAR_*, TF_TOKEN_*, and lowercase config names below are expected.

5. Variable reference

Every variable from the old .env.sample becomes a secret in the project (name = env var). 🔑 marks sensitive values; the rest is config. Store literal values. Example values use the old template defaults, so substitute your own. Gate = only needed when that module's *_enable flag is true.

Global / Terraform Cloud

Variable Value / how to obtain Purpose
TF_TOKEN_app_terraform_io 🔑 Terraform Cloud → User Settings → Tokens Auth to Terraform Cloud remote state; needed before terraform init
host_machine_architecture amd64 (or arm64) Build/target architecture
domain_host chrislee.local Base domain all service hostnames derive from

Stage 0: cloud provisioning

Only needed when provisioning cloud machines; stage 0 is a separate root that a LAN-only cluster never runs. The first four have no Terraform default, so running stage 0 without them fails the plan rather than producing an instance nothing can reach. Account setup walks through obtaining every value below.

Variable Value / how to obtain Purpose
TF_VAR_stage0_oci_accounts raw JSON object keyed by account label, see below OCI accounts and their node lists
TF_VAR_stage0_oci_private_keys 🔑 raw JSON object, same keys, PEM values OCI API signing keys
TF_VAR_stage0_ssh_public_key cat ~/.ssh/id_rsa.pub Injected into every provisioned node for the ubuntu user. Required: an empty value fails the plan
TF_VAR_stage0_tailscale_auth_key 🔑 admin console, tagged tag:k8s-node Read by cloud-init on every node in the apply. Required: an empty value fails the plan. More than one node at a time needs a reusable key, see Security posture
TF_VAR_stage0_ssh_ingress_cidrs [] Sources allowed to reach port 22 from the internet. Empty creates no rule. Entries must be IPv4 CIDRs of /24 or narrower
TF_VAR_stage0_budget_enable false Meaningless on an unupgraded Always Free account, which cannot be charged

One key must be exactly account1, because stage0/providers.tf looks it up by that literal. A second account is one more object here keyed account2, plus the two blocks it needs in stage0/, described in the account model. One apply covers every account.

{
  "account1": {
    "region": "ap-sydney-1",
    "tenancy_ocid": "ocid1.tenancy.oc1..aaaaaaaa...",
    "user_ocid": "ocid1.user.oc1..aaaaaaaa...",
    "fingerprint": "a1:b2:c3:d4:e5:f6:07:18:29:3a:4b:5c:6d:7e:8f:90",
    "compartment_ocid": "ocid1.compartment.oc1..aaaaaaaa...",
    "nodes": {
      "oci-worker-01": { "ocpus": 2, "memory_gbs": 12, "boot_disk_gbs": 100 }
    }
  }
}

The three shape fields are optional and default to the values shown, so "oci-worker-01": {} is equivalent. Each node key becomes the instance name, the Tailscale hostname and the Kubernetes node name.

The private key is multi-line, so build the JSON rather than pasting it. Run this on the host: the container's $HOME is a bind mount of container/root, so ~/.oci is empty there.

bws secret create TF_VAR_stage0_oci_private_keys "$(python3 -c '
import json, pathlib
print(json.dumps({"account1": pathlib.Path.home().joinpath(".oci/oci_api_key.pem").read_text()}))
')" "$BWS_PROJECT_ID"

TF_VAR_stage0_oci_accounts is deliberately not marked sensitive in Terraform. OCIDs and fingerprints are identifiers rather than secrets, and Terraform rejects for_each derived from a sensitive value, which the nodes map depends on.

Stage 1: cluster / Ansible

Variable Value / how to obtain Purpose
kubernetes_cluster_type kubeadm (or k3s / minikube) Which cluster provisioner to run
server_ssh_host 192.168.1.100 Control-plane node IP
server_ssh_user 🔑 your node SSH username SSH login user for provisioning
server_ssh_port 2222 SSH port (must not be 22)
worker_hosts_json [] or raw JSON array Worker node definitions (raw JSON, no escaping)
worker_default_taints [{"key":"node.homelab/class","value":"low-power","effect":"NoSchedule"}] Default taint for workers without their own
k3s_extra_server_args (empty) Extra args for the k3s server
k3s_token 🔑 openssl rand -base64 64 \| tr -d '\n' k3s node join secret
sshd_port 2222 Hardened sshd port (matches server_ssh_port)
wireguard_port 51820 WireGuard listen port
docker_default_data_path /var/lib/docker Docker data root
etc_hosts_json [] or raw JSON array Extra /etc/hosts entries (raw JSON)

Stage 1: Tailscale node transport (gate: tailscale_node_enable)

Separate from the Stage 2 Tailscale block below, and the two keys are not interchangeable: this one must carry tag:k8s-node, the stage 2 one carries tag:k8s-gateway, and the tailnet ACL grants them different access. See tailscale_node for how to generate the key.

Variable Value / how to obtain Purpose
tailscale_node_enable false Install host-level tailscaled on every cluster node
tailscale_auth_key 🔑 admin console, tagged tag:k8s-node Node join, read only on a node's first run
hostname_prefix homelab Prefix for tailnet machine names, no trailing dash

hostname_prefix is the stage 1 half of a value shared with stage 2's TF_VAR_hostname_prefix. Keep the two in step; they are separate secrets only because stage 1 reads lowercase environment variables and Terraform reads TF_VAR_*. The per-device suffix is not a secret: the control plane's is set in inventory.yml, each worker's in worker_hosts_json, and the gateway's is fixed by the stage 2 module.

Stage 0 has no hostname_prefix. Each node key in stage0_oci_accounts is the tailnet hostname verbatim, so name the keys to match the prefix by hand.

Stage 2: Kubernetes / ingress

Variable Value / how to obtain Purpose
TF_VAR_ingress_enable_tls true Generate TLS certs when true
TF_VAR_host_machine_architecture amd64 Passthrough of host_machine_architecture
TF_VAR_kubernetes_override_ip 192.168.1.100 Cluster external IP override
TF_VAR_kubernetes_override_domains space-separated domain list (flattened) Domains routed to the cluster

Stage 2: Nginx

Variable Value / how to obtain Purpose
TF_VAR_nginx_service_loadbalancer_ip 192.168.1.100 LoadBalancer IP for the ingress
TF_VAR_nginx_frontend_basic_auth_base64 🔑 htpasswd -nb user password \| openssl base64 HTTP basic-auth gate on the frontend
TF_VAR_nginx_client_max_body_size 10M Max request body size
TF_VAR_nginx_client_body_buffer_size 10M Request body buffer size

Stage 2: Cert Manager

Variable Value / how to obtain Purpose
TF_VAR_cert_manager_acme_email chris@chrislee.local ACME registration email
TF_VAR_cert_manager_ingress_class nginx Ingress class for ACME solver
TF_VAR_cert_manager_host_alias_ip 192.168.1.100 Hairpin-NAT host alias IP
TF_VAR_cert_manager_host_alias_hostnames comma-separated hostnames (flattened) Hairpin-NAT host aliases

Stage 2: Longhorn

Variable Value / how to obtain Purpose
TF_VAR_longhorn_default_settings_default_data_path /var/lib/longhorn Longhorn data path
TF_VAR_longhorn_ingress_class_name nginx Ingress class
TF_VAR_longhorn_ingress_host k8s.chrislee.local Longhorn UI host

Stage 2: MinIO

Variable Value / how to obtain Purpose
TF_VAR_minio_tenant_pools_size 100Gi Tenant pool capacity
TF_VAR_minio_tenant_ingress_class_name nginx Ingress class
TF_VAR_minio_tenant_ingress_api_host minio.chrislee.local S3 API host
TF_VAR_minio_tenant_ingress_console_host minio-console.chrislee.local Console host
TF_VAR_minio_internal_endpoint minio.minio-tenant.svc.cluster.local:80 In-cluster S3 endpoint. Must be the minio ClusterIP service on port 80

Stage 2: GitLab

Variable Value / how to obtain Purpose
TF_VAR_gitlab_global_hosts_domain chrislee.local GitLab base domain
TF_VAR_gitlab_global_hosts_host_suffix (empty) Optional host suffix
TF_VAR_gitlab_global_hosts_external_ip 192.168.1.100 External IP
TF_VAR_gitlab_global_ingress_class nginx Ingress class
TF_VAR_gitlab_global_ingress_provider nginx Ingress provider
TF_VAR_gitlab_certmanager_issuer_email chris@chrislee.local Issuer email
TF_VAR_gitlab_postgres_storage_size 20Gi CloudNativePG volume
TF_VAR_gitlab_valkey_persistence_size 2Gi Valkey volume
TF_VAR_gitlab_gitaly_persistence_size 50Gi Gitaly volume. Backs a StatefulSet claim template, which is immutable, so this must match the existing volume
TF_VAR_gitlab_toolbox_persistence_size 20Gi Toolbox volume
TF_VAR_gitlab_toolbox_backups_cron_persistence_size 30Gi Backup staging volume
TF_VAR_gitlab_runner_authentication_token 🔑 GitLab → Admin → CI/CD → Runners → New instance runner (allow untagged) Registers the CI runner
TF_VAR_gitlab_minio_host minio.chrislee.local Object storage host
TF_VAR_gitlab_minio_endpoint https://minio.chrislee.local Object storage endpoint
TF_VAR_gitlab_minio_use_https True Use HTTPS to object storage

Stage 2: Prometheus Stack

Variable Value / how to obtain Purpose
TF_VAR_prometheus_alertmanager_domain alertmanager.chrislee.local Alertmanager host
TF_VAR_prometheus_grafana_domain grafana.chrislee.local Grafana host
TF_VAR_prometheus_ingress_class_name nginx Ingress class
TF_VAR_prometheus_prometheus_domain prometheus.chrislee.local Prometheus host
TF_VAR_prometheus_persistence_size 10Gi Prometheus volume
TF_VAR_prometheus_alertmanager_slack_channel notification Slack channel for alerts
TF_VAR_prometheus_alertmanager_slack_credentials 🔑 api.slack.com/apps → Install App → Bot User OAuth Token Alertmanager → Slack delivery
TF_VAR_prometheus_minio_job_bearer_token 🔑 mc admin prometheus generate minio Scrape MinIO cluster metrics
TF_VAR_prometheus_minio_job_node_bearer_token 🔑 mc admin prometheus generate minio node Scrape MinIO node metrics
TF_VAR_prometheus_minio_job_bucket_bearer_token 🔑 mc admin prometheus generate minio bucket Scrape MinIO bucket metrics
TF_VAR_prometheus_minio_job_resource_bearer_token 🔑 mc admin prometheus generate minio resource Scrape MinIO resource metrics

The channel name carries no leading #. The values template adds it, so #notification renders as ##notification and every chat.postMessage fails with channel_not_found. terraform plan rejects a leading # rather than applying it.

Stage 2: Logging / Elasticsearch / Kibana

Variable Value / how to obtain Purpose
TF_VAR_logging_module_enable true Enable the logging module
TF_VAR_elasticsearch_storage_size 10Gi Elasticsearch volume
TF_VAR_kibana_ingress_class_name nginx Ingress class
TF_VAR_kibana_domain kibana.chrislee.local Kibana host

Stage 2: Kubecost

Variable Value / how to obtain Purpose
TF_VAR_kubecost_ingress_host cost.chrislee.local Kubecost host
TF_VAR_kubecost_ingress_class_name nginx Ingress class
TF_VAR_kubecost_cluster_id cluster-one Stamped into every ETL record. Changing it on a live install orphans the cost history
TF_VAR_kubecost_storage_class_name longhorn Storage class for the Kubecost volumes

Stage 2: Tailscale (gate: TF_VAR_tailscale_enable)

Variable Value / how to obtain Purpose
TF_VAR_tailscale_enable false Enable Tailscale
TF_VAR_tailscale_auth_key 🔑 admin console, tagged tag:k8s-gateway Gateway auth to the tailnet
TF_VAR_tailscale_advertise_routes 192.86.0.0/24 Subnet routes advertised
TF_VAR_hostname_prefix homelab Prefix for tailnet machine names, no trailing dash

The gateway registers as <prefix>-gateway, so the name is not separately configurable. Keep TF_VAR_hostname_prefix equal to stage 1's hostname_prefix.

The key must be reusable, because the pod re-authenticates on every container start rather than only on first join, and it must be re-issued before it expires or the pod stops starting. See issuing the auth key for the console procedure and the expiry trap.

Stage 2: WireGuard (gate: TF_VAR_wireguard_enable)

Variable Value / how to obtain Purpose
TF_VAR_wireguard_enable false Enable WireGuard
TF_VAR_wireguard_ingress_host vpn.chrislee.local VPN host
TF_VAR_wireguard_port 51820 Listen port

Stage 2: ArgoCD

Variable Value / how to obtain Purpose
TF_VAR_argocd_apps_repo_url (empty, or repo URL) Root apps repo for ApplicationSet
TF_VAR_argocd_config_repositories_json_encoded 🔑 [] Repository credentials rendered into configs.repositories; empty in this deployment
TF_VAR_argocd_domain argocd.chrislee.local ArgoCD host
TF_VAR_argocd_rbac_policy_default "" Optional fallback role for non-admin identities; empty requires explicit policy grants
TF_VAR_argocd_ssh_known_hosts_base64 "" SSH repository host keys; currently unused
TF_VAR_argocd_rbac_policy_csv multi-line RBAC CSV Extra RBAC policy rules

An exported TF_VAR_argocd_rbac_policy_default overrides the Terraform default of "" and grants every authenticated identity that role. Before applying, confirm that the intended TF_VAR_argocd_rbac_policy_csv grants human access, remove any stored default-role variable, reload without its already-exported value by running unset TF_VAR_argocd_rbac_policy_default; bws-load, and verify that the Terraform plan sets argocd-rbac-cm.data["policy.default"] to an empty string.

Stage 2: ArgoCD Image Updater (gate: TF_VAR_argocd_image_updater_enable)

Variable Value / how to obtain Purpose
TF_VAR_argocd_image_updater_enable false Enable image updater
TF_VAR_container_registry_prefix registry.chrislee.local Registry prefix
TF_VAR_container_registry_api_url https://registry.chrislee.local Registry API URL
TF_VAR_container_registry_credentials 🔑 GitLab deploy token, read_registry scope, as username:token Pull images from the registry
TF_VAR_argocd_apps_git_username argocd-image-updater Git user for write-back
TF_VAR_argocd_apps_git_password 🔑 GitLab project access token, write_repository scope Write image-tag bumps to argocd-apps repo

Stage 2: OAuth2 Proxy + Auth0

Variable Value / how to obtain Purpose
TF_VAR_auth_ingress_class_name nginx Ingress class
TF_VAR_auth_oauth2_proxy_host auth.chrislee.local OAuth2 proxy host
TF_VAR_auth_oauth2_proxy_cookie_domains [".chrislee.local"] Cookie domains
TF_VAR_auth_oauth2_proxy_whitelist_domains ["*.chrislee.local"] Redirect whitelist
TF_VAR_auth_auth0_domain 🔑 Auth0 dashboard → Application settings Auth0 tenant for SSO
TF_VAR_auth_auth0_client_id 🔑 Auth0 dashboard → Application settings Auth0 app identifier
TF_VAR_auth_auth0_client_secret 🔑 Auth0 dashboard → Application settings Auth0 app secret; gates all web services

Stage 2: Datadog (gate: TF_VAR_datadog_enable)

Variable Value / how to obtain Purpose
TF_VAR_datadog_enable false Enable Datadog
TF_VAR_datadog_site datadoghq.com Datadog site
TF_VAR_datadog_cluster_name homelab Cluster name tag
TF_VAR_datadog_api_key 🔑 Datadog → Org Settings → API Keys Agent ingestion
TF_VAR_datadog_app_key 🔑 Datadog → Org Settings → Application Keys API/app-scoped access

Stage 2: LiteLLM (gate: TF_VAR_litellm_enable)

Variable Value / how to obtain Purpose
TF_VAR_litellm_enable false Enable LiteLLM
TF_VAR_litellm_domain litellm.chrislee.local Proxy host, serves both /v1 and /ui
TF_VAR_litellm_ingress_class_name nginx Ingress class
TF_VAR_litellm_storage_size 10Gi Postgres volume size
TF_VAR_litellm_storage_class_name longhorn Storage class
TF_VAR_litellm_ui_paths ["/ui","/sso","/litellm-asset-prefix","/fallback/login","/login","/docs","/redoc","/openapi.json","/routes","/config/yaml","/public"] Paths routed behind oauth2-proxy. Anything omitted is served unauthenticated
TF_VAR_litellm_chart_version 1.89.2 litellm-helm chart pin. Bump together with the image tag
TF_VAR_litellm_image_tag 1.89.2 ghcr.io/berriai/litellm-database pin
TF_VAR_litellm_postgres_image_tag 18.4-alpine Must end in -alpine: the pod sets fs_group = 70
TF_VAR_litellm_replicas 1 Replica count
TF_VAR_litellm_master_key 🔑 echo "sk-$(openssl rand -hex 24)" Admin and API superuser key
TF_VAR_litellm_salt_key 🔑 echo "sk-$(openssl rand -hex 24)" Encrypts DB-stored provider credentials. Write once, never rotate
TF_VAR_litellm_db_password 🔑 openssl rand -hex 16 Postgres password, min 16 chars, only A-Z a-z 0-9 _ . ~ -. It is interpolated into a postgresql:// URI, so reserved characters would corrupt the connection string
TF_VAR_litellm_provider_secrets 🔑 JSON object, e.g. {"OPENAI_API_KEY":"sk-…","ANTHROPIC_API_KEY":"sk-ant-…"} Provider keys exported to the pod as env vars

Rotating TF_VAR_litellm_salt_key after models have been added through /ui makes every stored provider credential permanently unreadable. Treat it as write-once.

Stage 2: OmniRoute (gate: TF_VAR_omniroute_enable)

Variable Value / how to obtain Purpose
TF_VAR_omniroute_enable false Enable OmniRoute
TF_VAR_omniroute_domain omniroute.chrislee.local Host serving both the open API prefixes (/v1, /api/v1) and the dashboard
TF_VAR_omniroute_ingress_class_name nginx Ingress class for both ingresses
TF_VAR_omniroute_storage_size 5Gi SQLite volume size
TF_VAR_omniroute_storage_class_name longhorn Storage class
TF_VAR_omniroute_chart_version 0.2.2 omniroute chart pin. Bump together with the image tag
TF_VAR_omniroute_image_tag "" diegosouzapw/omniroute tag. Empty uses the chart appVersion; use -web for web-cookie providers
TF_VAR_omniroute_public_paths ["/api/v1", "/v1"] Paths routed to the open API ingress. Anything omitted is gated by oauth2-proxy
TF_VAR_omniroute_gated_admin_suffixes ["/management", "/agents", "/accounts", "/registered-keys"] Admin suffixes pulled back behind oauth2-proxy, applied to every public path prefix
TF_VAR_omniroute_initial_password 🔑 openssl rand -base64 24 First-boot dashboard password, min 12 chars
TF_VAR_omniroute_jwt_secret 🔑 openssl rand -hex 32 Signs dashboard sessions, min 32 chars. Rotatable
TF_VAR_omniroute_api_key_secret 🔑 openssl rand -hex 32 Encrypts stored provider keys, min 32 chars. Write once, never rotate
TF_VAR_omniroute_storage_encryption_key 🔑 openssl rand -hex 32 Encrypts the database at rest, min 32 chars. Write once, never rotate

The four credential variables populate the omniroute-auth Kubernetes Secret, mounted into the pod with envFrom as the keys JWT_SECRET, API_KEY_SECRET, INITIAL_PASSWORD, and STORAGE_ENCRYPTION_KEY.

Rotating TF_VAR_omniroute_api_key_secret (API_KEY_SECRET) or TF_VAR_omniroute_storage_encryption_key (STORAGE_ENCRYPTION_KEY) after providers have been added makes every stored credential permanently unreadable. Treat both as write-once. TF_VAR_omniroute_jwt_secret only signs sessions and may be rotated (it logs everyone out).

Stage 2: Cloudflare Tunnel (gate: TF_VAR_cloudflare_tunnel_enable)

Variable Value / how to obtain Purpose
TF_VAR_cloudflare_tunnel_enable false Enable the tunnel
TF_VAR_cloudflare_tunnel_token 🔑 Cloudflare → Zero Trust → Networks → Tunnels → copy --token eyJ… cloudflared connector auth
TF_VAR_cloudflare_tunnel_chart_version 0.1.2 Helm chart version
TF_VAR_cloudflare_tunnel_image_tag (empty) cloudflared image tag (empty = chart default)
TF_VAR_cloudflare_tunnel_replica_count 2 Replica count

6. Manage secrets day-to-day

set -a; . .env; set +a

bws secret list                       # list all (names + ids)
bws secret get <secret-id>            # read one
bws secret edit <secret-id> --value "<new value>"
bws secret delete <secret-id>

After editing a secret, run bws-load inside the container to pull the change into your current shell (see "Reload without restarting" above).

Rotate the access token: create a new token on the machine account, update .env, then revoke the old one. Least privilege: the machine account only needs can read.

7. Verify

task docker:exec                      # enter the container; .bashrc auto-injects secrets

# inside the container:
echo "$TF_VAR_prometheus_grafana_domain"     # -> grafana.chrislee.local (flattened, not ${domain_host})
task stage2:terraform:plan                   # authenticates via TF_TOKEN_app_terraform_io
task stage1:ansible:ping                     # reaches the cluster via injected SSH vars

Run secret-consuming tasks inside the container. Host-side terraform is only for no-secret ops (terraform providers lock, init -backend=false, fmt, validate). For CI/scripted runs, call bws run -- <command> directly, since .bashrc only auto-loads for interactive shells.