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.
| Stage | Purpose | Where it runs |
|---|---|---|
dev | Local development | Docker Compose via hearth/compose.yaml |
test | Automated testing, CI | CI runners, ephemeral |
qa | Pre-production review, demos, manual testing | k3s namespace qa, VPN-only |
prod | Production | k3s namespace default |
Stage selection
Every command that varies by stage takes --stage (default: dev):
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
| Property | Value |
|---|---|
| Domains | katforge.com, api.katforge.com, www.katforge.com |
| Routing | katforge.com/play/lextris/ serves the Lextris game |
| SSL | Let's Encrypt, auto-provisioned via Traefik (Route53 DNS challenge) |
| Namespace | default |
| Database | PostgreSQL 16 on the k3s host |
QA
QA is a parallel deployment in the same cluster, on a sibling namespace, behind the VPN.
| Property | Value |
|---|---|
| Domains | qa.katforge.com, qa.api.katforge.com |
| Access | VPN-only. Public DNS resolves to the private IP 172.31.14.98 |
| Namespace | qa |
| Database | shared 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.
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
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:
| Service | Internal | External | URL |
|---|---|---|---|
| Traefik | — | :4442/:4443 | https://*.dev.katforge.com:4443 |
| API | api:8000 | localhost:8100 | https://api.dev.katforge.com:4443 |
| Postgres | db:5432 | localhost:5433 | — |
| Mercure | mercure:80 | localhost:8181 | https://mercure.dev.katforge.com:4443 |
| pgAdmin | pgadmin:80 | localhost:5050 | https://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.
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
| File | Holds | Editable how |
|---|---|---|
<service>.yaml | URLs, ports, public flags, file paths, comments documenting the encrypted sibling | Any text editor; commit normally |
<service>.enc.yaml | API keys, OAuth client secrets, JWT passphrases, DB passwords | hearth 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.
# 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)
- Add the key to
hearth/env/<stage>/<service>.yamlfor every stage that needs it. - Add the var to
hearth/compose.yamlunder the service'senvironment:block, marking it required:YAMLenvironment: NEW_PUBLIC_FLAG: ${NEW_PUBLIC_FLAG:?required} - Reference the var in code (see Reading vars in code below).
hearth down && hearth upto 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)
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.
hearth secret diff dev prod
Editing values
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:
- Where the values live.
hearth/env/prod/<service>.{yaml,enc.yaml}instead ofdev/. - How they reach the container. Compose interpolation in dev. Kubernetes Secrets in prod.
The prod path
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:
- Reads
hearth/env/prod/<service>.yamland decrypts<service>.enc.yaml. - Builds a Kubernetes
Secretmanifest named<service>-secrets(e.g.api-secrets) with every key as astringDataentry. kubectl applys it. Idempotent: re-running with no changes is a no-op.
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:
# 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
| Resource | Why | How to manage |
|---|---|---|
mercure-secrets | Different shape (publisher-key, subscriber-key) | k8s/secrets.yaml.example, applied by hand |
registry-creds | Docker registry auth, type kubernetes.io/dockerconfigjson | kubectl 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.
// 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 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 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.