Deploy and manage retro game servers on AWS Fargate. Config-driven via .env files — add a new server by dropping a Dockerfile, .env, and project.json into apps/.
Servers scale to zero when nobody is playing, so an idle fleet costs almost nothing.
- Overview
- Installation
- AI Quick Start: Respawn
- Core Features
- Game Servers
- Configuration Reference
- Secrets
- Images
- Security
- Idle Shutdown
- Per-Server Setup
- Usage Examples
- Best Practices
- Contributing
The problem. A dedicated game server for a handful of friends is 95% idle, but a VPS bills around the clock and hand-rolled CDK per game is unmaintainable.
The approach. One CDK stack, parameterised entirely by a .env file per game. An idle-shutdown
sidecar watches the game port and scales the ECS service to zero after 30 quiet minutes. Adding a
game means adding a directory, not writing infrastructure code.
Key decisions:
- Discovery over registration.
apps/*/with a.envis a deployable server. No central list to update. - Fargate, not EC2. No hosts to patch. Spot by default in dev/staging.
- Secrets never in config. Passwords and tokens live in Secrets Manager / SSM and arrive as ECS secrets.
- Two image strategies. Use an upstream image when it reads env vars; build a thin wrapper when it doesn't.
Who should use this: anyone running a few game servers for friends who wants them cheap and reproducible, and is comfortable with AWS CDK.
asdf install # node 24.13.0, python 3.14.2 (.tool-versions)
pnpm install
aws sso login --profile respawn # your AWS profile; region us-east-1You also need Docker running, but only for services that build their own image.
Bootstrap CDK once per account/region:
npx cdk bootstrap --profile respawn- Deploying a containerised, single-instance game server to AWS Fargate
- The server is reachable on one primary UDP/TCP port and tolerates scale-to-zero
- You want per-game config in a file, not in CDK code
- You need multi-region, multi-instance, or matchmaking-aware fleets
- The game requires a persistent public IP across restarts (Fargate tasks get a new IP on every start)
- The server cannot tolerate a cold start. The idle sidecar stops the task and nothing brings it
back automatically — you restart it with
pnpm respawn:deployor by settingdesiredCountto 1. A cold start is 30–90s, much longer if a SteamCMD install must re-run
Add a server whose upstream image is configured by environment variables:
mkdir -p apps/minetest# apps/minetest/Dockerfile — required even when IMAGE_URI is set
FROM lscr.io/linuxserver/minetest:latest# apps/minetest/.env
SERVICE_NAME=minetest
SERVICE_DISPLAY_NAME="Minetest Server"
IMAGE_URI=lscr.io/linuxserver/minetest:latest
CPU=512
MEMORY=1024
CONTAINER_PORT=30000
HOST_PORT=30000
PROTOCOL=UDP
ENABLE_IDLE_SHUTDOWN=true
# Ask the game for its player count. `netstat` is for TCP games ONLY — it cannot
# see players on a UDP game and would scale a full server to zero. See Idle shutdown.
IDLE_CHECK_METHOD=a2s
IDLE_TIMEOUT_MINUTES=30
GAME_ENV_SERVERNAME="Respawn Minetest"
AWS_ACCOUNT_ID=123456789012
AWS_REGION=us-east-1
AWS_PROFILE=respawn// apps/minetest/project.json
{
"name": "minetest",
"projectType": "application",
"tags": ["type:app", "lang:dockerfile"]
}pnpm respawn # interactive menu -> Deploy -> minetest- Always used with: AWS CDK v2, Nx (targets + affected graph), pnpm workspaces
- Usually used with: Docker (only for services that build an image), AWS SSO
- Replaces: hand-written per-game CDK stacks,
docker-composeon a permanently-billing VPS
- A service without
.envdoes not exist. Discovery skips it silently — no warning. - Never put a secret in
CONTAINER_COMMANDorGAME_ENV_*. Both land in the ECS task definition in plaintext. UseSECRET_REFS. - Every
SECRET_REFSentry must exist before the first deploy, or the task dies withResourceInitializationError. CPUandMEMORYmust be a valid Fargate pair — validated at load, fails fast.- Docker build context is the repo root, so
COPYpaths areapps/<name>/.... - Declare anything the server cannot run without in
REQUIRED_ENV_VARS— a GSLT, an admin Steam64 ID. Deploy refuses to proceed if one is unset or still a placeholder.
# Wrong — plaintext secret, readable by anyone with ECS read access
CONTAINER_COMMAND=+rcon_password hunter2
GAME_ENV_RCON_PASSWORD=hunter2
# Correct — injected as an ECS secret
SECRET_REFS=RCON_PASSWORD=sm:respawn/cs16/rcon# Wrong — dotenv strips `#...` as an inline comment, silently truncating the ref
SECRET_REFS=DB=sm:respawn/app/db#password
# Correct — the jsonKey delimiter is `|`
SECRET_REFS=DB=sm:respawn/app/db|password# Silently ignored — environment overrides are applied AFTER .env is parsed
USE_FARGATE_SPOT=false # dev/staging always force spot ON; prod forces it OFF
LOG_RETENTION_DAYS=99 # always 7 (dev) / 14 (staging) / 30 (prod)# Breaks after a redeploy — a service with no EFS re-downloads its game files
# on every cold start, including every wake from idle shutdown.
ENABLE_PERSISTENT_STORAGE=false # wrong for any SteamCMD-installed game- Scale to zero: an idle sidecar asks the game how many players are on, and stops the task after
IDLE_TIMEOUT_MINUTES - Zero-code onboarding: three files in
apps/<name>/and the CLI finds it - First-class secrets:
SECRET_REFS→ Secrets Manager / SSM → ECSsecrets:, neverenvironment: - Persistent worlds: optional EFS volume, transit-encrypted, IAM-authorised
- Deploy-time prompts: pick a gamemode at deploy without editing config (
DEPLOY_PROMPTS) - Deploy preflight: refuses to deploy when a required value is a placeholder or a referenced secret is absent
- Interactive or scripted: Clack menu by default,
--non-interactivefor CI - Mid-game control (optional): an MCP server lets an LLM change maps, kick players, and set cvars over rcon — see
apps/respawn-mcp
| Server | Image | CPU | Memory | Port | EFS | Secrets |
|---|---|---|---|---|---|---|
| Valheim | ghcr.io/lloesche/valheim-server |
1024 | 4096 MB | UDP 2456 | yes | yes |
| Unreal Tournament 99 | roemer/ut99-server |
512 | 1024 MB | UDP 7777 | no | yes |
| Team Fortress Classic | local build (jives/hlds:tfc) |
256 | 512 MB | UDP 27015 | no | yes |
| Team Fortress 2 | cm2network/tf2 |
1024 | 2048 MB | UDP 27015 | no | yes |
| Counter-Strike 1.6 | local build (jives/hlds:cstrike) |
256 | 512 MB | UDP 27015 | no | yes |
| Counter-Strike: Source | local build (LinuxGSM) | 1024 | 2048 MB | UDP 27015 | yes | yes |
| Counter-Strike 2 | cm2network/cs2 |
2048 | 4096 MB | UDP 27015 | yes | yes |
| Garry's Mod | local build (LinuxGSM) | 2048 | 4096 MB | UDP 27015 | yes | yes |
| Left 4 Dead 2 | left4devops/l4d2 |
512 | 2048 MB | UDP 27015 | no | yes |
| Doom 2 (Zandronum) | rcdailey/zandronum-server |
256 | 512 MB | UDP 10666 | yes | no |
| Quake 3 Arena | inanimate/quake3 |
256 | 512 MB | UDP 27960 | yes | no |
| Quake Live | dpadgett/ql-docker |
256 | 512 MB | UDP 27960 | no | no |
| 7 Days to Die | vinanrra/7dtd-server |
2048 | 8192 MB | UDP 26900 | yes | no |
| Rust | didstopia/rust-server |
4096 | 16384 MB | UDP 28015 | yes | yes |
Local build means IMAGE_URI is unset: the Dockerfile layers a respawn-init.sh shim over the
upstream image and is pushed to ECR. See Contributing.
Security note. Every credential in every service is now referenced via
SECRET_REFSand stored in Secrets Manager.loader.tsrejects a plaintext credential inGAME_ENV_*orCONTAINER_COMMANDat config load, so this cannot regress silently.
Every key below is read from apps/<name>/.env by apps/respawn/src/config/loader.ts.
Unknown keys are ignored; malformed ones fail fast at load.
| Key | Default | Description |
|---|---|---|
SERVICE_NAME |
directory name | Stack and cluster name component |
SERVICE_DISPLAY_NAME |
SERVICE_NAME |
Human label in the CLI |
IMAGE_URI |
— | Upstream image. If unset, Dockerfile is built and pushed to ECR |
DOCKERFILE_PATH |
./Dockerfile |
Relative to the service directory |
| Key | Default | Description |
|---|---|---|
CPU |
1024 |
Fargate CPU units. One of 256, 512, 1024, 2048, 4096 |
MEMORY |
2048 |
MiB. Must be legal for the chosen CPU |
CONTAINER_COMMAND |
image default | Split on whitespace. Never put secrets here |
CONTAINER_PORT / HOST_PORT |
7777 |
Primary game port |
PROTOCOL |
UDP |
UDP or TCP |
ADDITIONAL_PORTS |
— | port/proto or host:container/proto, comma-separated |
INTERNAL_PORTS |
— | Ports the task uses but that get no public ingress (rcon, web, telnet) — reachable only in-task over loopback |
ENABLE_PUBLIC_ACCESS |
true |
Public subnet + security group ingress |
| Key | Default | Description |
|---|---|---|
ENABLE_IDLE_SHUTDOWN |
true |
Scale to zero when idle |
IDLE_TIMEOUT_MINUTES |
30 |
Quiet minutes before stopping |
IDLE_CHECK_METHOD |
netstat |
a2s, q3, gamespy, http (needs IDLE_STATUS_ENDPOINT), or netstat |
IDLE_QUERY_PORT |
game port | Port the query probe hits (Rust: 28017, UT99: game port + 1) |
IDLE_QUERY_TIMEOUT_SECONDS |
4 |
Reply deadline before the probe reports "unknown" |
IDLE_CHECK_INTERVAL_SECONDS |
60 |
Poll interval |
USE_FARGATE_SPOT |
true |
Overridden by environment — see below |
DESIRED_COUNT |
1 |
Task count |
ENABLE_AUTOSCALING |
false |
With MIN_CAPACITY, MAX_CAPACITY, AUTOSCALE_CPU_TARGET |
| Key | Default | Description |
|---|---|---|
ENABLE_PERSISTENT_STORAGE |
false |
EFS volume. Required for any SteamCMD-installed game |
PERSISTENT_MOUNT_PATH |
/data |
Where the volume mounts |
SECRET_REFS |
— | ENV_VAR=<sm|ssm>:<sourceId>[|jsonKey], comma-separated |
GAME_ENV_* |
— | Prefix stripped, passed as container env. Not for secrets |
DEPLOY_PROMPTS |
— | ENV_VAR:select:a|b|c — asked at deploy, overrides GAME_ENV_* |
REQUIRED_ENV_VARS |
— | Comma-separated container env vars the server cannot run without. Checked at deploy time |
ENABLE_REDIS_SIDECAR |
false |
Redis sidecar (Quake Live's minqlx uses this) |
LOG_RETENTION_DAYS |
14 |
Overridden by environment — see below |
AWS_ACCOUNT_ID / AWS_REGION / AWS_PROFILE |
— / us-east-1 / — |
Deploy target |
dev (default), staging, prod. Overrides are applied after .env is parsed, so these two
keys in .env are silently ignored:
| Environment | LOG_RETENTION_DAYS |
USE_FARGATE_SPOT |
|---|---|---|
dev |
7 | true |
staging |
14 | true |
prod |
30 | false (plus MIN_CAPACITY=1) |
Passwords and tokens must never sit in .env, the task definition, or CloudWatch logs. Reference
them instead; ECS injects them as secrets: at container start.
# apps/cs16/.env
SECRET_REFS=RCON_PASSWORD=sm:respawn/cs16/rconNaming convention: respawn/<service>/<name> for Secrets Manager, /respawn/<service>/<name> for SSM.
Set or rotate the value — masked input, written straight to AWS, never echoed or persisted:
pnpm respawn # -> Secrets -> cs16 -> RCON_PASSWORDRead one back when you need it:
aws secretsmanager get-secret-value --secret-id respawn/cs16/rcon \
--profile respawn --query SecretString --output textThree things that bite:
- A referenced secret must exist before the first deploy. ECS resolves secrets before starting
the container, and CDK only synthesises an ARN — it never checks existence. A missing one fails the
task with
ResourceInitializationError. - Making a secret optional means deleting its entry, not leaving the store empty. An SSM SecureString cannot hold an empty value.
- The
jsonKeydelimiter is|, not#—dotenvtreats#as an inline comment and truncates the value silently.
Full specification: artifacts/AGENT_PROMPT.md §7.
Services either pull an upstream image (IMAGE_URI set) or build one from their Dockerfile.
Built images are tagged by content, never by git SHA:
sha-<12 hex> = hash( Dockerfile + every COPYed file + digest of the FROM base )
pnpm respawn:push builds and pushes without deploying. deploy computes the same tag and
skips the build entirely when ECR already holds it — a no-op push takes ~3 seconds instead
of rebuilding several hundred MB.
Why not the git SHA? It is wrong in both directions. git rev-parse HEAD ignores the working
tree, so an uncommitted edit to respawn-init.sh would reuse a stale image while looking
correct. And an unrelated commit changes the SHA, forcing a pointless rebuild and push. Hashing
the FROM digest also means an upstream republish of a mutable tag (jives/hlds:cstrike)
correctly forces a rebuild.
| Flag | Effect |
|---|---|
--forceBuild |
Rebuild and push even when the tag is already in ECR |
--requireImage |
Refuse to build; fail unless the image is already in ECR (CI/CD) |
dev-latest is kept as a human-friendly moving pointer; CDK always pins the immutable content tag.
Only game ports are public. The security group opens the primary port and
ADDITIONAL_PORTS (client, query, SourceTV — traffic players need) to the
internet. Admin surfaces — RCON, web panels, telnet — go in INTERNAL_PORTS:
they are declared on the task and reachable in-task over loopback, but the
security group opens no ingress for them. Nothing outside the task can reach them.
rcon runs inside the task. The rcon-control sidecar (ENABLE_RCON_CONTROL)
talks to the game over loopback, so the rcon password never crosses the public
network. Remote control is via ECS Exec (SSM): TLS + IAM, a customer KMS key, and
an audit log — no inbound port. See apps/respawn-mcp.
The game port itself is public by design (players connect from anywhere) and relies on:
- AWS Shield Standard — automatic, free L3/L4 DDoS protection on the ENI.
sv_password/ GSLT — set a join password via the MCP (set_server_password) or a secret, and a Steam token for listing where the engine supports it.- Game-level query-flood protection — most engines rate-limit A2S/status queries; the idle probe treats a rate-limited reply as "unknown", never "empty".
To lock a specific server to known IPs (a private/friends server), restrict its security group to your CIDRs — the game port is the only thing left open.
A sidecar polls the game, and after IDLE_TIMEOUT_MINUTES with nobody on it scales the ECS
service to zero. Nothing scales it back up — restart with pnpm respawn:deploy, or:
aws ecs update-service --cluster respawn-dev-cs16 --service respawn-dev-cs16 \
--desired-count 1 --profile respawnUDP game servers hand every client a single unconnected socket, so counting established
sockets reports zero however many people are playing. netstat is therefore correct only
for TCP games — on a UDP game it will scale a full server to zero mid-match.
Ask the game instead. Each service declares its own probe; the sidecar knows nothing game-specific.
| Method | Protocol | Used by |
|---|---|---|
a2s |
Valve A2S_INFO | cs16, css, cs2, gmod, tfc, tf2, l4d2, rust, 7dtd |
q3 |
idTech3 getstatus |
quake3, quakelive |
gamespy |
Unreal Engine 1 \info\ |
ut99 |
zandronum |
Zandronum launcher protocol (Huffman-coded) | doom2 |
http |
GET IDLE_STATUS_ENDPOINT, read .connections / .players |
valheim |
netstat |
established sockets | TCP games only |
Set IDLE_QUERY_PORT when the game answers somewhere other than its game port — Rust uses
28017, UT99 uses game port + 1.
A probe that times out, hits the wrong port, or speaks the wrong protocol returns -1
("unknown"), never 0. The watchdog holds the idle timer on unknown rather than treating it
as empty. A misconfigured probe therefore costs money — the server never sleeps — but can
never end a live match. Could not determine player count in the sidecar log is that signal.
Two probes are unverified against a live server and marked so in their .env: 7dtd and
quakelive. Every other probe — including zandronum — was tested against a real server.
Zandronum rate-limits queries (sv_queryignoretime). The probe reports that as unknown,
never as empty, so flood protection cannot be mistaken for an idle server.
A Game Server Login Token (GSLT) is mandatory — CS2 will not start without one. Generate at https://steamcommunity.com/dev/managegameservers using AppID 730, then store both secrets:
pnpm respawn # -> Secrets -> cs2 -> SRCDS_TOKEN, CS2_RCONPWEFS is required: the SteamCMD install is ~60 GB and would otherwise re-download on every cold start.
Gamemode is chosen at deploy time (competitive, casual, deathmatch, wingman).
Both use LinuxGSM images, which are configured by files rather than env vars, so each layers a
respawn-init.sh shim that writes <game>server.cfg from the injected environment before handing
off to the upstream entrypoint. Both need EFS at /data.
GSLT is optional — it only controls public listing. To run unlisted, delete the GSLT= entry
from SECRET_REFS; leaving it referenced but unset will fail the task.
Garry's Mod asks for a gamemode at deploy time (ttt, prop_hunt, darkrp), and its task is sized
for the heaviest one.
No GSLT and no game files to supply. HLDS takes its settings as command-line arguments, but its
entrypoint forwards them without eval, so a secret can never be referenced there. The shim writes
cstrike/server.cfg (which GoldSrc execs at map start) from RCON_PASSWORD instead.
A GSLT is required for TF2 to appear in the public server browser. Generate one at https://steamcommunity.com/dev/managegameservers using AppID 440.
The server still accepts direct connections without a token, but won't be listed publicly.
TF2 currently runs unlisted: there is no SRCDS_TOKEN entry in its SECRET_REFS. To list it,
store a token and append ,SRCDS_TOKEN=ssm:/respawn/tf2/gslt. Its rcon password is already a secret.
Zandronum requires a WAD file. Provide one of:
doom2.wad— from your Doom 2 purchase (Steam, GOG, etc.). Typically found at:- Steam (Linux):
~/.local/share/Steam/steamapps/common/Doom 2/base/doom2.wad - Steam (Windows):
C:\Program Files (x86)\Steam\steamapps\common\Doom 2\base\doom2.wad - GOG: check the install directory under
base/
- Steam (Linux):
freedoom2.wad— a free, open-source alternative from https://freedoom.github.io/
Upload the WAD to the EFS volume mounted at /data/ before starting the server. If using Freedoom,
update CONTAINER_COMMAND in apps/doom2/.env to reference freedoom2.wad instead.
To load mods (Brutal Doom, etc.), place the .pk3/.wad files on the same EFS volume and append to
the container command:
CONTAINER_COMMAND=-iwad /data/doom2.wad -file /data/brutalv21.pk3 -port 10666 ...
Zandronum also supports Heretic, Hexen, and Strife — just swap the WAD file.
Idle shutdown uses IDLE_CHECK_METHOD=zandronum, which speaks Zandronum's Huffman-coded
launcher protocol. Its query flood protection (sv_queryignoretime) is reported as unknown
rather than empty, so a rate-limited reply never scales a populated server to zero.
Requires pak0.pk3 from your retail install:
- Steam (Linux):
~/.local/share/Steam/steamapps/common/Quake 3 Arena/baseq3/pak0.pk3 - Steam (Windows):
C:\Program Files (x86)\Steam\steamapps\common\Quake 3 Arena\baseq3\pak0.pk3 - GOG: check the install directory under
baseq3/
Upload it to the EFS volume mounted at /usr/share/games/quake3/baseq3/ before starting the server.
Optionally place a server.cfg alongside it to customise map rotation, fraglimit, timelimit, bot
config, and RCON password. See the docker-quake3 repo.
Free-to-play — the server downloads game files via SteamCMD automatically on first start.
- Set
GAME_ENV_admininapps/quakelive/.envto your Steam64 ID (find yours at https://steamid.io/). This grants you automatic RCON access in-game. - The image includes minqlx (plugin framework), which uses Redis for persistent data (map votes,
ELO tracking).
ENABLE_REDIS_SIDECAR=trueprovides it. Without Redis the base server still runs.
SteamCMD downloads everything automatically on first boot (~15 GB). First startup takes 10–15 minutes depending on network speed.
- EFS is critical. Without it the 15 GB download repeats on every container restart. World saves and backups also live on EFS.
- Resource-heavy. The default (2 vCPU / 8 GB) suits 4–8 players. For larger or heavily modded
servers, scale to
CPU=4096/MEMORY=16384. - Server config lives at
/home/sdtdserver/serverfiles/sdtdserver.xmlon EFS. Edit after first boot to set server name, password, max players, and world settings. - Web control panel (8080) and telnet (8081) are exposed but require passwords set in
sdtdserver.xmlbefore use. - Mods are supported via env vars — see
apps/7dtd/.env.examplefor Alloc Fixes, CPM, Undead Legacy, and Darkness Falls options.
No game files or tokens needed. Game modes are configured in apps/l4d2/.env:
GAME_ENV_DEFAULT_MODE=coop # coop, versus, realism, survival, scavenge
GAME_ENV_DEFAULT_MAP=c1m1_hotel # Dead Center campaign start
The RCON password comes from SECRET_REFS (sm:respawn/l4d2/rcon) — the image reads the same
RCON_PASSWORD env var, so no shim is needed.
SteamCMD installs the game on first boot; no purchase or token needed to run the server. Sized at
4 vCPU / 16 GB by default. See apps/rust/README.md for wipe handling,
scaling, and Rust+ companion-app setup.
Both take a password via SECRET_REFS (SERVER_PASS and UT_ADMINPWD). Set them before the first
deploy.
pnpm respawn # interactive: pick action, environment, servicenpx nx run respawn:cdk --nonInteractive --action=deploy \
--profile=respawn --environment=prod --service=cs16,cssProd forces USE_FARGATE_SPOT=false and MIN_CAPACITY=1, so tasks stop being interrupted — and
stop being cheap.
pnpm respawn:synth # render CloudFormation
pnpm respawn:diff # diff against deployed state
pnpm respawn:status # running tasks, public IPs
pnpm respawn:push # build + push images to ECR, no deploy
respawn:synth,:diff,:deploy,:destroy, and:statuspass a hardcoded--servicelist inpackage.json, currently naming all 14 servers. Add new services to those lists, or use the interactivepnpm respawnmenu, which discovers them automatically.
| Symptom | Cause |
|---|---|
| Service missing from the CLI menu | No .env in apps/<name>/ — discovery skips it silently |
Task stops with ResourceInitializationError |
A SECRET_REFS entry names a secret that doesn't exist |
Config rejected at load with Invalid memory … |
CPU/MEMORY are not a legal Fargate pair |
| Long cold start on every wake | ENABLE_PERSISTENT_STORAGE=false on a SteamCMD game |
| Server never scales to zero | Probe reports "unknown" — wrong IDLE_CHECK_METHOD/IDLE_QUERY_PORT. Check sidecar logs |
| Server scaled to zero mid-game | IDLE_CHECK_METHOD=netstat on a UDP game; use the game's query protocol |
docker build cannot find a COPY source |
Build context is the repo root; use apps/<name>/… |
.env change to spot or log retention has no effect |
Overridden by the environment (dev/staging/prod) |
# Read why a task died
aws ecs describe-tasks --cluster respawn-dev-cs16 --tasks <task-arn> \
--profile respawn --query 'tasks[0].stoppedReason'Configuration. Keep .env.example in sync with .env — the example is the only tracked copy,
and .env is gitignored. Set AWS_ACCOUNT_ID explicitly rather than relying on ambient credentials.
Cost. Leave ENABLE_IDLE_SHUTDOWN=true. An idle fleet with scale-to-zero costs only EFS storage
and log retention. Spot is on by default outside prod.
Persistence. Any game whose image runs SteamCMD needs ENABLE_PERSISTENT_STORAGE=true, or the
full install repeats on every wake from idle. Confirm the image's install path before setting
PERSISTENT_MOUNT_PATH — LinuxGSM uses /data, cm2network/cs2 uses /home/steam/cs2-dedicated.
Security. Secrets go through SECRET_REFS, never GAME_ENV_* or CONTAINER_COMMAND. Rotate by
re-running the Secrets action and redeploying. Fargate tasks get a new public IP on every start, so
don't hand out a bare IP for anything long-lived.
Testing. Config parsing and validation are the bug-prone parts and are unit-tested. Add specs to
apps/respawn/src/config/loader.spec.ts for any new key, including its rejection cases.
Create apps/<name>/ with four files:
Dockerfile— usually a singleFROM <upstream-image>.env— deployment config. Gitignored; nothing deploys without it.env.example— the tracked template. Keep it in syncproject.json— the Nx descriptor:{ "name": "<name>", "projectType": "application", "tags": ["type:app", "lang:dockerfile"] }
Discovery picks up any apps/ directory that has a .env, plus either a Dockerfile or an
IMAGE_URI. No CDK or CLI changes required.
Then add the service to the --service lists in package.json for the convenience scripts.
Optionally (recommended) add a README.md documenting anything server-specific — resource
sizing, world/save persistence, idle-shutdown quirks, wipe/update handling, and admin access. See
apps/rust/README.md for the template.
Some images are configured by files, or forward arguments without eval so a secret can never be
referenced on the command line. For these, leave IMAGE_URI unset and layer a shim:
# apps/<name>/Dockerfile — build context is the repo root
FROM upstream/image:tag
COPY apps/<name>/respawn-init.sh /respawn-init.sh
ENTRYPOINT ["/bin/sh", "/respawn-init.sh"]The shim writes the game's config file from injected env vars, then execs the upstream entrypoint.
Check the base image's USER first: a RUN chmod +x fails on images that drop to a non-root user,
which is why the entrypoint above invokes /bin/sh explicitly.
Working examples: apps/gmod and apps/css (LinuxGSM), apps/cs16 (HLDS).
pnpm typecheck && pnpm lint && pnpm test && pnpm buildDevelopment standards, patterns, and gotchas live in CLAUDE.md.