Docs/Hearth/Environments

Environments

KATforge follows Symfony's stage convention everywhere (env vars, config files, Docker Compose profiles, k8s namespaces, branch strategies). Don't use development, local, staging, production, live.

StagePurposeWhere it runs
devLocal developmentDocker Compose via hearth/compose.yaml
testAutomated testing, CICI runners, ephemeral
qaPre-production review, demos, manual testingk3s namespace qa, VPN-only
prodProductionk3s namespace default

Stage selection

Every command that varies by stage takes --stage (default: dev):

shell
hearth up --stage qa
hearth ship --stage qa
hearth status --stage qa
hearth secret list dev
hearth secret edit prod api
hearth secret apply prod

The stage controls which hearth/env/<stage>/*.yaml files are loaded and which k8s namespace is targeted.

Production

PropertyValue
Domainskatforge.com, api.katforge.com, www.katforge.com
Routingkatforge.com/play/lextris/ serves the Lextris game
SSLLet's Encrypt, auto-provisioned via Traefik (Route53 DNS challenge)
Namespacedefault
DatabasePostgreSQL 16 on the k3s host

QA

QA is a parallel deployment in the same cluster, on a sibling namespace, behind the VPN.

PropertyValue
Domainsqa.katforge.com, qa.api.katforge.com
AccessVPN-only. Public DNS resolves to the private IP 172.31.14.98
Namespaceqa
Databaseshared PostgreSQL (same instance as prod)

QA shares the prod database. That's intentional, it's a low-traffic studio environment for demos and manual tests, not isolated load testing. Treat any QA write as visible to prod schema and prod data.

shell
hearth ship --stage qa             # deploy everything to QA
hearth ship api --stage qa         # deploy only the API to QA
hearth status --stage qa

QA has no basic auth. Network-level VPN access is the gate.

Local development

shell
hearth up                          # start the dev stack
hearth up -d                       # detached
hearth dev katforge                # then run a frontend dev server

Default stage is dev. The compose stack starts:

ServiceInternalExternalURL
Traefik:4442/:4443https://*.dev.katforge.com:4443
APIapi:8000localhost:8100https://api.dev.katforge.com:4443
Postgresdb:5432localhost:5433
Mercuremercure:80localhost:8181https://mercure.dev.katforge.com:4443
pgAdminpgadmin:80localhost:5050https://pgadmin.dev.katforge.com:4443

See Local dev for the full topology and Networking for hostnames and TLS.

Test stage

The test stage is for automated CI runs (ephemeral databases, fixture-loaded seed data, no external secrets). CI workflows pass APP_ENV=test and a disposable DATABASE_URL. There's no persistent test infrastructure.


Env files

Hearth keeps every service's configuration in hearth/env/<stage>/<service>.yaml (plaintext) and <service>.enc.yaml (sops-encrypted). Both file types are committed to git. The encrypted ones are safe to commit because only people whose age public keys are listed in hearth/.sops.yaml can decrypt.

text
hearth/env/
├── dev/
│   ├── api.yaml             # plaintext config + comments documenting encrypted siblings
│   ├── api.enc.yaml         # sops-encrypted secrets (API keys, OAuth, JWT passphrases)
│   ├── stumper.yaml         # vite VITE_* (no enc sibling, frontends have no secrets)
│   └── geargoblins.yaml
├── test/
│   ├── api.yaml
│   └── api.enc.yaml
└── prod/
    ├── api.yaml
    ├── api.enc.yaml
    └── stumper.yaml

There is no qa/ directory by default. QA reuses prod values unless a service publishes a stage-specific override.

Plaintext vs encrypted

FileHoldsEditable how
<service>.yamlURLs, ports, public flags, file paths, comments documenting the encrypted siblingAny text editor; commit normally
<service>.enc.yamlAPI keys, OAuth client secrets, JWT passphrases, DB passwordshearth secret edit <stage> <service> only

sops encrypts the entire YAML body of .enc.yaml, so comments inside it never reach GitHub. Document encrypted keys in the plaintext sibling instead — that file becomes the section map for the pair.

YAML
# hearth/env/dev/api.yaml

# === Application identity ===
APP_ENV: "dev"
APP_NAME: "katforge-api"
APP_DEBUG: "1"

# === Application secrets (encrypted) ===
# APP_SECRET   — Symfony framework secret. Signs CSRF tokens and session cookies.
# HMAC_SECRET  — Internal HMAC for signing email links and passport tokens.

# === Database ===
DATABASE_URL: "postgresql://katforge:katforge@db:5432/katforge?serverVersion=16"

# === OAuth (encrypted) ===
# OAUTH_DISCORD_ID, OAUTH_DISCORD_SECRET   — discord.com/developers
# OAUTH_GOOGLE_ID,  OAUTH_GOOGLE_SECRET    — Google Cloud Console

The matching api.enc.yaml has the same keys with real values, encrypted.

The shared namespace

Any hearth/env/<stage>/shared.yaml (or shared.enc.yaml) is loaded before every service's own files. Use it for values that more than one service consumes (e.g. a third-party API key shared between api and a worker). Per-service files override shared.

Adding a variable

The flow depends on whether the value is a secret.

Plaintext (URLs, flags, paths)

  1. Add the key to hearth/env/<stage>/<service>.yaml for every stage that needs it.
  2. Add the var to hearth/compose.yaml under the service's environment: block, marking it required:
    YAML
    environment:
       NEW_PUBLIC_FLAG: ${NEW_PUBLIC_FLAG:?required}
    
  3. Reference the var in code (see Reading vars in code below).
  4. hearth down && hearth up to reload.

The :? suffix fails compose at parse time if the var is empty or undefined. The bare ? form (no colon) only fails on undefined; empty is fine. Use ? for optional values like Apple OAuth that aren't always populated.

Secret (API keys, passwords, signing keys)

shell
hearth secret edit dev api    # opens $EDITOR with decrypted plaintext
# Add NEW_KEY: real-value-here, save and quit. sops re-encrypts on close.
hearth secret edit qa api     # mirror to other stages
hearth secret edit prod api

Then add the same ${NEW_KEY:?required} line to hearth/compose.yaml, reference it in code, and (for prod) push it to k8s with hearth secret apply prod (see Production-specific vars).

Mirroring across stages

hearth secret diff dev prod shows which keys are defined in one stage but missing from another. Run it after adding a variable to catch stages you forgot.

shell
hearth secret diff dev prod

Editing values

shell
hearth secret edit <stage> <service>

Decrypts the .enc.yaml to a temp file, opens $EDITOR, then re-encrypts on save. The temp file is removed regardless of how the editor exits. See Secrets for the sops mechanics, recipient management, and key rotation.

For plaintext files, just edit them like any other YAML and commit the diff.

Production-specific vars

Production differs from dev in two places:

  1. Where the values live. hearth/env/prod/<service>.{yaml,enc.yaml} instead of dev/.
  2. How they reach the container. Compose interpolation in dev. Kubernetes Secrets in prod.

The prod path

text
hearth/env/prod/api.yaml          plaintext + sops-decrypted .enc.yaml
   ↓ hearth secret apply prod
Secret/api-secrets in k8s         envFrom: secretRef: api-secrets
   ↓ pod startup
$_ENV inside the api container    every key now an environment variable

hearth secret apply prod is the production equivalent of hearth up. It:

  1. Reads hearth/env/prod/<service>.yaml and decrypts <service>.enc.yaml.
  2. Builds a Kubernetes Secret manifest named <service>-secrets (e.g. api-secrets) with every key as a stringData entry.
  3. kubectl applys it. Idempotent: re-running with no changes is a no-op.
shell
hearth secret apply prod              # push every service's prod env to k8s
hearth secret apply prod --dry-run    # preview what would change
hearth secret apply qa                # same, for the qa namespace

The deployment manifest references the secret by name:

YAML
# hearth/k8s/api/deployment.yaml
spec:
   template:
      spec:
         containers:
            - name: api
              envFrom:
                 - secretRef:
                      name: api-secrets

envFrom projects every key/value pair into the container's environment as if you'd listed each one under env: individually. New keys added via hearth secret apply become available after the next pod restart.

Frontend (vite) prod values

Vite frontends are different. Their values are baked into the bundle at build time, not read at runtime, so they don't go through Kubernetes Secrets. hearth ship stumper reads hearth/env/prod/stumper.yaml and passes each key as docker build --build-arg. The stumper Dockerfile then declares ARG VITE_API_URL and assigns it to the build environment so Vite can replace import.meta.env.VITE_API_URL references.

This is opt-in per service via Service.uses_build_env=True in cli/src/hearth/config.py. Currently only stumper opts in. The other frontends use defaults compiled into their Vite/Nuxt configs.

Things hearth secret apply doesn't handle

ResourceWhyHow to manage
mercure-secretsDifferent shape (publisher-key, subscriber-key)k8s/secrets.yaml.example, applied by hand
registry-credsDocker registry auth, type kubernetes.io/dockerconfigjsonkubectl create secret docker-registry

These are static, set-once-per-cluster resources. Hearth's env files are the wrong place for them.

Reading vars in code

Once a value lands in the container's environment (via compose in dev or envFrom in prod), each runtime exposes it differently.

PHP / Symfony — api.katforge.comPHP
// 1. Direct access (rare):
$secret = $_ENV['HMAC_SECRET'];        // or $_SERVER['HMAC_SECRET']

// 2. As bound arguments in config/services.yaml (preferred):
//
//    services:
//       KAT\Service\Avatars:
//          arguments:
//             $bucket:   '%env(S3_ASSETS_BUCKET)%'
//             $maxBytes: '%env(int:AVATAR_UPLOAD_MAX_BYTES)%'
//
//    Type casts (int:, bool:, json:, base64:) coerce at container-compile time.

// 3. In framework config (e.g. config/packages/doctrine.yaml):
//
//    doctrine:
//       dbal:
//          url: '%env(resolve:DATABASE_URL)%'
//
//    resolve: interpolates other %env(...)% references inside the string.
Vite — stumper, lextris, geargoblinsTypeScript
// Vite exposes env vars prefixed with VITE_ to client code via import.meta.env.
// Values without that prefix are NOT included in the bundle.

export const client = createClient ({
   baseUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:8100'
});

// These are baked at build time. Changing VITE_API_URL requires a rebuild,
// not just a pod restart. In dev, Vite re-evaluates on hot reload.
Nuxt — katforge.comTypeScript
// Nuxt reads env vars via useRuntimeConfig(). Public values (sent to the
// client) live under runtimeConfig.public; server-only values stay at the
// top level. NUXT_* / NUXT_PUBLIC_* env vars override the defaults.

// nuxt.config.ts
export default defineNuxtConfig ({
   runtimeConfig: {
      dbHost: process.env.NUXT_DB_HOST,                  // server-only
      public: {
         apiUrl: process.env.NUXT_PUBLIC_API_URL          // client-visible
            || 'https://api.katforge.com'
      }
   }
});

// component
const config = useRuntimeConfig ();
const apiUrl = config.public.apiUrl;

// Unlike Vite's import.meta.env, Nuxt's runtime config is evaluated
// server-side at request time, so a pod restart picks up new values without
// a rebuild.