Every GoBuild hub process loads one Settings object from its own .env (app/config.py:12). The stack is whole-stack-per-client: each instance — the home/demo box and every customer clone — has its own .env, so secrets, database, and backends are per-instance and never shared (app/config.py:1-5). The code (Caddyfile, docker-compose.prod.yml) is byte-identical everywhere; only .env differs, so git pull never conflicts.
Settings are read via the cached singleton get_settings() (app/config.py:353), which fails fast in production if SECRET_KEY is unset/default/< 32 chars (app/config.py:359-365).
🔴 = security-sensitive (signing keys, provider tokens, encryption keys). Handle per Security hardening and review Known risks before rotating. This page documents KEYS only — never the values, which live in .env (gitignored).
Config lives in three layers:
| Layer |
Where |
Loaded by |
| Hub app settings |
.env → Settings |
app/config.py:12 (pydantic-settings) |
| Compose orchestration |
docker-compose.prod.yml environment: blocks |
Docker, overrides some .env values |
| Control plane (ops) |
raw os.environ (CP_*) |
controlplane/*.py, no pydantic model |
Env var names are the UPPER_SNAKE form of the field (pydantic case-insensitive). ${VAR:-default} in compose means "from .env, else this default".
| Key |
Purpose |
Default |
Consumed |
KEYSTONE_ENV |
Environment: local | staging | production. Gates prod fail-fast + secure cookies. |
local |
app/config.py:18, .py:346; controlplane/main.py:43 |
🔴 SECRET_KEY |
JWT signing key (HS256). App refuses to boot in prod if default/short. Generate openssl rand -hex 32. |
dev-secret-change-me |
app/config.py:19,359; Security |
CLIENT_SLUG |
Identifies which tenant this whole-stack belongs to. |
demo |
app/config.py:20 |
🔴 DATABASE_URL |
This instance's Postgres DSN. Compose overrides to the hub-db service with ${POSTGRES_PASSWORD}. |
postgresql+psycopg://keystone:keystone@localhost:5432/keystone |
app/config.py:70; compose :48 |
REDIS_URL |
Job queue / cache. Compose overrides to redis://redis:6379/0. |
redis://localhost:6379/0 |
app/config.py:227; compose :49 |
ACCESS_TOKEN_EXPIRE_MINUTES |
Auth token lifetime (12h). |
720 |
app/config.py:73 |
JWT_ALGORITHM |
Token algorithm. |
HS256 |
app/config.py:74 |
APP_BASE_URL |
Canonical URL used in outbound email links. |
https://app.gobuild.ca |
app/config.py:237 |
APP_GIT_SHA |
Short SHA baked at build (Dockerfile ARG GIT_SHA). Surfaced at /_admin/version for drift detection. |
unknown |
app/config.py:43 |
¶ Per-droplet domains (compose / Caddy)
Set in .env, read by Caddyfile + docker-compose.prod.yml; blank ⇒ fall back to home-instance gobuild.ca hosts.
| Key |
Purpose |
Default |
APP_DOMAIN |
Hub back-office host. |
app.gobuild.ca |
SIGN_DOMAIN / SIGN_URL |
Documenso signer host / URL. |
sign.gobuild.ca |
CHAT_DOMAIN / CHAT_URL |
Chatwoot host / URL. |
chat.gobuild.ca |
OPS_DOMAIN |
Control-plane host. |
ops.gobuild.ca |
SHOWCASE_DOMAIN |
Prospect showcase host. |
showcase.gobuild.ca |
WIKI_DOMAIN |
This internal KB host. |
wiki.gobuild.ca |
MARKETING_DOMAINS / MARKETING_URL |
Root marketing site host(s). |
gobuild.ca, www.gobuild.ca |
| Key |
Purpose |
Default |
Consumed |
TENANT_BASE_DOMAIN |
DNS zone for POOLED tenant hubs — org acme lives at acme.app.gobuild.ca. Own zone so tenant hubs never collide with partner marketing subdomains. Compose (dev) sets app.dev.gobuild.ca. |
app.gobuild.ca |
app/config.py:28; compose :55 |
🔴 TENANT_SECRET_KEY |
Fernet key encrypting per-org integration credentials (BYO Stripe/Twilio/QBO…) at rest in tenant_credentials. Falls back to AD_TOKEN_ENCRYPTION_KEY if unset. Generate Fernet.generate_key(). Rotation re-encrypts all stored creds. |
"" |
app/config.py:67; app/services/credentials.py, secretbox.py; Security · Known risks |
The credential wall-off model (per-tenant keys via CredentialResolver, pooled orgs BYO, no global fallback) rides on TENANT_SECRET_KEY — see Security hardening.
The ops (and showcase) containers reuse the hub image with a different entrypoint and read raw os.environ — there is no pydantic model for these (controlplane/*.py). They hold no client data.
| Key |
Purpose |
Default |
Consumed |
🔴 CP_PUBLIC_KEY_PATH / CP_PUBLIC_KEY |
On the hub: RS256 PUBLIC PEM of the control plane. The hub Admin API (/_admin/*) only accepts JWTs the CP signed. Empty ⇒ Admin API disabled (fail-closed). Path form avoids multiline-PEM-in-env. Mounted ./secrets/cp_public.pem. |
"" |
app/config.py:37-38; compose :53,66; Security |
🔴 CP_SIGNING_KEY_PATH / CP_SIGNING_KEY |
On ops: RS256 PRIVATE PEM the CP signs hub Admin-API tokens with. Mounted ./secrets/cp_private.pem. Unset ⇒ CP cannot call any hub. |
"" |
controlplane/security.py:33-46; compose :216; Known risks |
🔴 CP_SECRET |
Cookie-signing secret for CP admin sessions (HMAC). Falls back to a CP_PASSWORD-derived hash. |
derived from CP_PASSWORD |
controlplane/security.py:27; compose :210,234 |
🔴 CP_PASSWORD |
Legacy single-admin CP login password (also seeds CP_SECRET fallback). |
"" |
controlplane/security.py:28; compose :209 |
CP_USER |
Legacy single-admin CP username. |
admin |
compose :208 |
CP_DATABASE_URL |
Control-plane DB (Phase-1 god-mode). SQLite fallback in the cp_data volume. |
sqlite:////app/cp-data/cp.db |
controlplane/db.py:15; compose :213 |
🔴 CP_BOOTSTRAP_EMAIL / CP_BOOTSTRAP_PASSWORD |
Creates the first PlatformAdmin on ops boot. Unset after first boot. |
"" |
controlplane/bootstrap.py:31-32; compose :214-215 |
CP_REGISTRY |
Path to registry.json (client roster). |
/app/clients/registry.json |
controlplane/main.py:41; compose :211 |
CP_ATLAS_BASE |
Marketing base URL for the atlas (single-source deploy). |
${MARKETING_URL:-https://gobuild.ca} |
compose :219 |
HUB_INTERNAL_URL |
VPC-private address of the pooled hub the CP drives. |
http://hub:8000 |
controlplane/bootstrap.py:50; compose :217 |
HUB_NAME / HUB_KIND / HUB_REGION |
Hub registration metadata seeded on boot. |
GoBuild pooled hub / pooled / "" |
controlplane/bootstrap.py:55-57 |
🔴 DIGITALOCEAN_TOKEN |
DO API token — droplet provisioning, DNS, billing. Presence gates all DO ops. |
unset |
controlplane/do_client.py:22,26; Security |
DO_REGION / DO_SIZE / DO_IMAGE |
Defaults for provisioning new tenant droplets. |
tor1 / s-2vcpu-4gb / docker-20-04 |
controlplane/provisioning.py:99-101 |
TENANT_DNS_DOMAIN |
Zone the CP writes A-records into when provisioning. |
gobuild.ca |
controlplane/provisioning.py:123 |
| Key |
Purpose |
Default |
Consumed |
MONITOR_ENABLED |
Poll every hub, open/resolve incidents. |
True |
app/config.py:48 |
MONITOR_INTERVAL_SECONDS |
Poll cadence. |
180 |
app/config.py:49 |
ALERT_EMAIL |
Incident email recipient. |
gautam@gobuild.ca |
app/config.py:50 |
ALERT_SMS_TO |
E.164 number for critical SMS alerts; empty ⇒ SMS skipped. |
"" |
app/config.py:51 |
MONITOR_DISK_WARN_PCT / MONITOR_DISK_CRIT_PCT |
Disk-usage alert thresholds. |
85 / 95 |
app/config.py:53-54 |
MONITOR_CERT_DOMAINS |
Comma-separated hosts for TLS-expiry checks; empty ⇒ skip. |
"" |
app/config.py:55 |
MONITOR_CERT_WARN_DAYS |
Cert-expiry warning window. |
14 |
app/config.py:56 |
DO_IGNORE_DROPLETS |
Non-GoBuild droplets excluded from billing/usage. |
smolmac,livv.ca |
app/config.py:58 |
DO_PROTECTED_DROPLETS |
Marketing droplets shown but LOCKED from reboot/resize/destroy. |
GoBuild-App,GoBuild-CRM |
app/config.py:61 |
| Key |
Purpose |
Default |
Consumed |
🔴 DEEPSEEK_API_KEY |
Primary LLM (chat/reasoning). Base URL swaps to a US host before real client data. |
"" |
app/config.py:89 |
DEEPSEEK_BASE_URL |
DeepSeek API host (one-line swap point). |
https://api.deepseek.com |
app/config.py:90 |
DEEPSEEK_MODEL |
Model name. |
deepseek-chat |
app/config.py:91 |
🔴 GEMINI_API_KEY |
Vision + image + grounded-trends. |
"" |
app/config.py:113 |
GEMINI_MODEL / GEMINI_IMAGE_MODEL |
Text / image model ids. |
gemini-2.5-flash / gemini-2.5-flash-image |
app/config.py:114-115 |
GEMINI_BASE_URL |
Gemini API host. |
https://generativelanguage.googleapis.com |
app/config.py:116 |
🔴 DEEPGRAM_API_KEY |
Voice → text (field app dictation). |
"" |
app/config.py:112 |
🔴 TEMPLATED_API_KEY |
Templated.io visual engine for AI Studio. One GoBuild key covers all tenants; dormant until set. |
"" |
app/config.py:316,320 |
TEMPLATED_BASE_URL |
Templated.io host. |
https://api.templated.io/v1 |
app/config.py:317 |
Most integrations are dormant until their key(s) are set — the *_configured properties (app/config.py) gate the UI/sync, and the underlying features still work without them. Per-org BYO credentials are resolved by CredentialResolver, not these stack-wide fields, when a pooled tenant supplies their own.
| Key |
Purpose |
Default |
Consumed |
🔴 RESEND_API_KEY |
Transactional + marketing email (HTTPS API — DO blocks SMTP). Also injected into Documenso. |
"" |
app/config.py:119; compose :91 |
RESEND_FROM |
Default From address. |
updates@demo.keystone.app |
app/config.py:120 |
🔴 RESEND_INBOUND_SECRET |
Shared secret guarding /api/webhooks/email/inbound. Unset ⇒ receiver inert. |
"" |
app/config.py:125 |
MARKETING_REPLY_TO |
Reply-To for campaigns/nurture; empty ⇒ RESEND_FROM. |
"" |
app/config.py:129 |
🔴 TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN |
Twilio API creds (server-side only). |
"" |
app/config.py:133-134 |
TWILIO_FROM |
E.164 Twilio sending number. |
"" |
app/config.py:135 |
| Key |
Purpose |
Default |
Consumed |
🔴 DOCUMENSO_API_TOKEN |
Documenso API token (Settings → API Tokens). |
"" |
app/config.py:241 |
DOCUMENSO_URL |
Public signer-facing URL. |
https://sign.gobuild.ca |
app/config.py:242 |
🔴 DOCUMENSO_WEBHOOK_SECRET |
Guards inbound Documenso webhook. Unset ⇒ inert (status polling still works). |
"" |
app/config.py:246 |
🔴 NEXTAUTH_SECRET, NEXT_PRIVATE_ENCRYPTION_KEY, NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY |
Documenso container secrets (its own auth + at-rest encryption). |
— |
compose :81-83 |
🔴 DOCUMENSO_SIGNING_PASSPHRASE |
Passphrase for the local PKCS#12 signing cert (certs/documenso.p12). |
— |
compose :96 |
DOCUMENSO_FROM_ADDRESS |
Documenso sender. |
updates@gobuild.ca |
compose :92 |
🔴 CHATWOOT_API_TOKEN |
Chatwoot account admin access token. |
"" |
app/config.py:250 |
CHATWOOT_URL |
Public/agent URL. |
https://chat.gobuild.ca |
app/config.py:249 |
CHATWOOT_ACCOUNT_ID / CHATWOOT_WEBSITE_TOKEN / CHATWOOT_SMS_INBOX_ID |
Account id, web-widget inbox token, Twilio SMS inbox for outbound routing. |
0 / "" / 0 |
app/config.py:251-253 |
🔴 CHATWOOT_WEBHOOK_SECRET |
Guards /api/webhooks/chatwoot. Unset ⇒ inert. |
"" |
app/config.py:257 |
🔴 CHATWOOT_SECRET_KEY_BASE |
Chatwoot container secret. |
— |
compose :163 |
| Key |
Purpose |
Default |
Consumed |
🔴 QBO_CLIENT_ID / QBO_CLIENT_SECRET |
Intuit OAuth app creds. qbo_configured gates the Connect card. |
"" |
app/config.py:199-200,206 |
QBO_ENVIRONMENT |
sandbox | production — picks the Intuit API base. |
sandbox |
app/config.py:201,209 |
QBO_REDIRECT_URI |
OAuth callback …/portal/integrations/quickbooks/callback. |
"" |
app/config.py:202 |
🔴 QBO_WEBHOOK_VERIFIER_TOKEN |
Per-app Intuit token validating webhook HMAC. |
"" |
app/config.py:203 |
🔴 XERO_CLIENT_ID / XERO_CLIENT_SECRET |
Xero OAuth app creds. xero_configured gates the Connect card. |
"" |
app/config.py:218-219,223 |
XERO_REDIRECT_URI |
OAuth callback. |
"" |
app/config.py:220 |
| Key |
Purpose |
Default |
Consumed |
🔴 STRIPE_SECRET_KEY |
Stripe API secret (Phase 4). |
"" |
app/config.py:192 |
🔴 STRIPE_WEBHOOK_SECRET |
Verifies Stripe webhook signatures. |
"" |
app/config.py:193 |
| Key |
Purpose |
Default |
Consumed |
🔴 GOOGLE_ADS_DEVELOPER_TOKEN |
Stack-wide Google Ads dev token. |
"" |
app/config.py:270,325 |
🔴 GOOGLE_ADS_CLIENT_ID / GOOGLE_ADS_CLIENT_SECRET |
OAuth client (Cloud Console). google_ads_oauth_ready gates the connect flow. |
"" |
app/config.py:271-272,285 |
GOOGLE_ADS_LOGIN_CUSTOMER_ID |
Our manager (MCC) account id. |
"" |
app/config.py:273 |
GOOGLE_ADS_REDIRECT_URI |
OAuth callback. |
…/portal/integrations/google-ads/callback |
app/config.py:275 |
GOOGLE_ADS_API_VERSION |
API major; bump on 404 (no code change). |
v22 |
app/config.py:278 |
GOOGLE_ADS_ENABLED |
Master gate for Google Ads dashboard/sync. |
False |
app/config.py:279,325 |
🔴 BING_ADS_DEVELOPER_TOKEN / BING_ADS_CLIENT_ID / BING_ADS_CLIENT_SECRET |
Microsoft (Bing) Ads creds — scaffolded, off until launch. |
"" |
app/config.py:288-290,330 |
BING_ADS_ENVIRONMENT / BING_ADS_ENABLED |
sandbox|production; master gate. |
sandbox / False |
app/config.py:291-292 |
🔴 AD_TOKEN_ENCRYPTION_KEY |
Fernet key encrypting stored ad-account refresh tokens. Also the fallback for TENANT_SECRET_KEY. Rotation re-links accounts. |
"" |
app/config.py:295; used by credentials/secretbox |
🔴 META_APP_ID / META_APP_SECRET |
Per-INSTANCE Meta (FB/IG) app creds. meta_oauth_ready gates Connect. Client OAuths their own Page/IG/ad account; no GoBuild master token stored. |
"" |
app/config.py:303-304,337 |
META_GRAPH_VERSION / META_REDIRECT_URI |
Graph API version / OAuth callback. |
v21.0 / …/portal/integrations/meta/callback |
app/config.py:305-306 |
META_ENABLED |
Master gate for live publish/ads (studio works without it). |
False |
app/config.py:307,343 |
🔴 META_SYSTEM_USER_TOKEN / META_BUSINESS_ID |
Only for the alternate GoBuild-managed System-User mode — unused in the per-instance model. |
"" |
app/config.py:310-311 |
| Key |
Purpose |
Default |
Consumed |
🔴 BUILDDATA_API_KEY |
BuildData.ca permit/zoning/contractor intel (RapidAPI). Dormant until set. |
"" |
app/config.py:140,145 |
BUILDDATA_HOST / BUILDDATA_BASE_URL |
RapidAPI host / base. |
builddata-…rapidapi.com |
app/config.py:141-142 |
🔴 DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD |
Optional keyword/trends source for blog planner; falls back to Gemini. |
"" |
app/config.py:152-153,157 |
🔴 MAPBOX_PUBLIC_TOKEN |
pk.… URL-restricted token — the only one that may reach the browser. |
"" |
app/config.py:108 |
🔴 MAPBOX_SECRET_TOKEN |
sk.… server-side only (static maps, account APIs). Never embed in a page. |
"" |
app/config.py:109 |
🔴 IPINFO_TOKEN |
GeoIP for the Live visitor board; keyless ip-api.com fallback in dev. |
"" |
app/config.py:103 |
LIVE_GEOIP_ENABLED |
Resolve visitor IP → city/region; off ⇒ store IP only. |
True |
app/config.py:102 |
| Key |
Purpose |
Default |
Consumed |
🔴 GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
Google Calendar two-way sync OAuth. gcal_configured gates it. |
"" |
app/config.py:162-163,167 |
GOOGLE_REDIRECT_URI |
OAuth callback. |
"" |
app/config.py:164 |
🔴 VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY |
Web Push (VAPID) keypair (scripts/gen_vapid_keys.py). Dormant if blank. |
"" |
app/config.py:173-174,178 |
VAPID_SUBJECT |
mailto:/https: contact for push. |
mailto:ops@gobuild.ca |
app/config.py:175 |
| Key |
Purpose |
Default |
Consumed |
OPENPROJECT_BASE_URL |
This instance's OpenProject. Compose sets internal http://openproject:80. |
http://localhost:8080 |
app/config.py:77; compose :56 |
🔴 OPENPROJECT_API_KEY |
Per-user API token (Basic auth apikey:<token>). |
"" |
app/config.py:78 |
🔴 OPENPROJECT_WEBHOOK_SECRET |
Inbound webhook signature secret. |
"" |
app/config.py:79 |
OP_TYPE_TASK / OP_TYPE_MILESTONE / OP_TYPE_PHASE |
Work-package type ids (stock install defaults). |
1 / 2 / 3 |
app/config.py:84-86 |
🔴 OPENPROJECT_SECRET_KEY_BASE |
OpenProject container secret. |
— |
compose :31 |
STORAGE_BACKEND defaults to local — uploads go to the persistent Docker volume at STORAGE_DIR. The Spaces (DigitalOcean S3) credentials exist in config and .env.example but are currently unused until STORAGE_BACKEND=spaces is flipped.
| Key |
Purpose |
Default |
Consumed |
STORAGE_BACKEND |
local | spaces. Currently local everywhere. |
local |
app/config.py:183; app/services/storage.py |
STORAGE_DIR |
Local backend root (mounted volume). Compose sets /data/uploads. |
/data/uploads |
app/config.py:184; compose :57 |
🔴 SPACES_KEY / SPACES_SECRET |
DO Spaces S3 creds. Unused while backend is local. |
"" |
app/config.py:185-186 |
SPACES_REGION / SPACES_BUCKET / SPACES_ENDPOINT |
Spaces target. |
nyc3 / keystone-demo / https://nyc3.digitaloceanspaces.com |
app/config.py:187-189 |
MinIO is a separate self-hosted S3 the Documenso container uploads PDFs to (not the hub's upload store):
| Key |
Purpose |
Consumed |
🔴 MINIO_ROOT_USER / MINIO_ROOT_PASSWORD |
MinIO root creds; also injected as Documenso's S3 access key/secret. |
compose :102-103, :249+ |
| Key |
Purpose |
Default |
Consumed |
DEMO_MODE |
Pitch-deck overlay + demo seeding. ON only on the home box (1); NEVER 1 on a customer clone. |
False |
app/config.py:96; compose :60 |
MONITOR_ENABLED |
Fleet monitor loop (ops). |
True |
app/config.py:48 |
LIVE_GEOIP_ENABLED |
Visitor GeoIP resolution. |
True |
app/config.py:102 |
GOOGLE_ADS_ENABLED / BING_ADS_ENABLED / META_ENABLED |
Per-platform ad master gates. |
False |
app/config.py:279,292,307 |
DIGEST_HOUR_UTC |
Daily-digest send hour (UTC); -1 disables. |
13 |
app/config.py:238 |
🔴 ALERTS_CRON_TOKEN |
Bearer for /internal/run-alerts (sent as X-Alerts-Token, not a URL query). Falls back to SECRET_KEY — set a distinct prod value so the JWT key never hits the wire. |
"" |
app/config.py:233; Security |
GOBUILD_MARKETING_ORG_ID |
Maps the static marketing site's visitors/chat into this org's Live app. Empty ⇒ tracking off. |
"" |
app/config.py:262 |
GOBUILD_MARKETING_HOSTS |
Marketing hostnames to attribute. |
gobuild.ca,www.gobuild.ca |
app/config.py:263 |
*_configured / *_ready derived properties (not env vars) roll these up: builddata_configured, dataforseo_configured, gcal_configured, push_configured, qbo_configured, xero_configured, templated_configured, google_ads_configured, google_ads_oauth_ready, bing_ads_configured, meta_oauth_ready, meta_configured, is_production (app/config.py:144-347).
Control-plane (ops) changes ride the hub image — redeploy with up -d --build hub ops, not --build ops alone. See the deploy memory note.