Hatch is a Postgres and one API image, and that image also serves every app.
compose.yaml at the repository root runs both on one computer, and it is the
default: everything on this page is about that file. A cluster is the other
way to run it (Kubernetes), and no manifests ship with Hatch.
What runs#
| Service | What it is | Lifecycle |
|---|---|---|
db |
Postgres, built from containers/hatch-db/Dockerfile.db: postgres:18.4 plus pginit.sql, which on a fresh volume creates the hatch and quartz databases and Quartz's tables. Its healthcheck is pg_isready. Port 5432 is published, because this is the developer's stack and its tests reach Postgres from the host |
Runs until stopped |
migrate |
The API image with HATCH_MIGRATE=1, which makes it apply the schema and nothing else |
Runs to completion on every start, then exits, and exited is correct: that is the state api waits for |
api |
The API, and every app it serves, on 8080 inside the container and HATCH_PORT outside it |
Starts only once migrate has completed successfully, and runs until stopped |
runner |
The container runner, under the profile runner |
Off unless that profile is named |
design |
The @hatch/ui gallery on 5173, a Vite dev server, under the profile design |
Off unless that profile is named |
migrate and api are the same image. Because migrate is a separate
service, a schema change is applied before the new API is started rather than
raced by it, on every start and not only the first. A stack whose migrate
shows Exited (0) is a healthy stack.
Two volumes hold what outlives the containers. pgdata (hatch-local_pgdata)
is the data: the board, the comments, the keys. workspace holds the container
runner's clones and is not data; nothing in it cannot be cloned again. The line
name: hatch-local at the top of compose.yaml fixes the project, the network
and the volume names, so they are the same wherever the command is run from.
Starting it#
docker compose up -d
Then open <your-hatch-origin>/apps/hatch/, which on one computer is
http://localhost:8080/apps/hatch/.
Port 8080 is only the default. HATCH_PORT moves it:
macOS / Linux:
HATCH_PORT=9090 docker compose up -d
PowerShell:
$env:HATCH_PORT=9090; docker compose up -d
Or put a HATCH_PORT=9090 line in the .env beside compose.yaml, which
Compose reads on its own, and the port stays moved on every later command.
A profile is named on each command, because Compose starts nothing under a profile nobody named:
docker compose --profile runner up -d
Building it or pulling it#
Compose builds every image from the checkout: db, migrate, api and
runner each have a build: and pull_policy: build, so every up rebuilds
them. That is why git pull and then up -d is an upgrade
(upgrading). The build is all cache when nothing changed.
The same images are also published. .github/workflows/publish.yml pushes them
to the GitHub container registry after every green build of the trunk:
| Image | Tags |
|---|---|
ghcr.io/<owner>/hatch-api |
latest; the commit sha; and <YYYYMMDDHHmmss>-<sha>, which is there for image automation to order builds by |
ghcr.io/<owner>/hatch-db |
latest and the commit sha |
ghcr.io/<owner>/hatch-runner |
latest and the commit sha, for linux/amd64 and linux/arm64 |
<owner> is the account the repository lives under. migrate and api are
both hatch-api.
To run from the images instead of building, put a compose.override.yaml
beside compose.yaml. Compose reads it on its own and lays it over the
file, so nothing in compose.yaml is edited:
services:
db:
image: ghcr.io/<owner>/hatch-db:latest
pull_policy: always
migrate:
image: ghcr.io/<owner>/hatch-api:latest
pull_policy: always
api:
image: ghcr.io/<owner>/hatch-api:latest
pull_policy: always
runner:
image: ghcr.io/<owner>/hatch-runner:latest
pull_policy: always
Leave out runner if you do not use it. Then fetch the images and start them:
docker compose pull
docker compose up -d
A sha tag pins a version; latest follows the trunk. If the packages are
private, docker login ghcr.io first, with a token that can read packages.
The variables#
compose.yaml is set from the shell or from a .env beside it, and
Environment variables has every one. The operator's
eight: HATCH_PORT; HATCH_AUTH → Auth__Enabled; the three
HATCH_GOOGLE_* variables → Auth__Google__*; HATCH_PUBLIC_URL →
Apps__PublicBaseUrl; and the two HATCH_ANDROID_* variables →
Apps__AssetLinks__0__…. The runner's own variables are on
the container runner. api passes the
shell's own USERNAME/USER as Auth__LocalPerson__Name, so there is
nothing to set for that one.
Where secrets go#
- The
.envbesidecompose.yaml. Git-ignored (.gitignore:13-17). src/Hatch.Api/.env.json. Bind-mounted at/App/.env.json(compose.yaml:80) and git-ignored (.gitignore:12). It loads after the environment, so it wins (Program.cs:56-62). It holdsha_host,ha_portandha_token, imported once into site settings (Services/DeviceMapping/DeviceMappingSeeder.cs:131-146), andvm_log_shipper_token(Controllers/VmConsoleLogsController.cs:29). A board needs none of them.- Never the repository, a commit, an issue or a plan (
docs/ethos.md). - In a cluster, a Secret: see Kubernetes.
What the API reads#
Each key below is the environment spelling (__ per level) of an
appsettings.json section. Defaults are the initialisers; the only hosts
named anywhere here are localhost, db and compose's own service names.
| Key | Default | What it does |
|---|---|---|
Auth__*, bound by Services/Auth/AuthOptions.cs |
||
Auth__Enabled |
false |
Whether the wall refuses anything at all; false leaves the app behaving exactly as it did before auth existed. |
Auth__EnforceInProcess |
true |
Whether this pod also refuses on its own, rather than only answering /api/auth/verify on Traefik's behalf. |
Auth__CookieName |
__Secure-hatch_grant |
The cookie the grant token rides in. |
Auth__CookieDomain |
empty | The cookie's Domain attribute; empty makes the cookie host-only. |
Auth__ExemptHosts__0 |
empty | A hostname the gate never challenges, whatever the path. |
Auth__InviteTtlMinutes |
15 |
How long an invite code is worth typing. |
Auth__BootstrapInviteTtlMinutes |
60 |
TTL for the invite the migration mints into an empty install. |
Auth__GrantRenewAfterDays |
30 |
Re-issues the cookie once it is this old. |
Auth__LastSeenThrottleSeconds |
60 |
Floor on how often a grant's last-seen time is written. |
Auth__SignInPath |
/apps/auth/ |
Where a document request with no grant is sent. |
Auth__RedeemAttemptsPerWindow |
10 |
Redemption attempts one client IP may spend per window. |
Auth__RedeemWindowSeconds |
60 |
The window RedeemAttemptsPerWindow is counted over. |
Auth__LocalPerson__Name |
empty | Who local mode says is sitting at the machine when there is no wall to ask. api passes the shell's own USERNAME/USER here, so there is nothing to set. |
Auth__Google__ClientId |
empty | The Google sign-in client's id. |
Auth__Google__ClientSecret |
empty | A secret. The Google sign-in client's secret. |
Auth__Google__RedirectUri |
empty | Where Google sends a person back to; set it behind a TLS proxy, where the scheme and host the API sees are not the ones Google has registered. |
Apps__*, bound by Modules/AppsOptions.cs |
||
Apps__PublicBaseUrl |
empty | The address a browser opens this Hatch at, printed into a QR label or a pull request's link back to its ticket. |
Apps__AssetLinks__0__PackageName |
empty | One phone's installed app, trusted to open this install's links without the user granting it by hand. |
Apps__AssetLinks__0__Sha256CertFingerprints__0 |
empty | That app's signing certificate fingerprint. |
Hatch__*, bound by Modules/Hatch/HatchOptions.cs |
||
Hatch__ClaimTtlSeconds |
300 |
How long a claim survives without a heartbeat. |
Hatch__RunnerGoneAfterSeconds |
90 |
How long a runner may go without a heartbeat before its row reads gone. |
Hatch__StallLapseMinutes |
5 |
How long a claim may go untouched before it is considered quiet. |
Hatch__StallResumeMinutes |
15 |
How long a marked issue is folded before the loop picks it back up on its own. |
Hatch__StallResumeLimit |
3 |
How many consecutive stalls an issue may resume through before it holds for a person instead. |
Hatch__DoneHorizonHours |
48 |
How long a card sits in a terminal column before the board stops handing it over. |
| The database | ||
ConnectionStrings__Hatch |
Host=localhost |
Where hatch's own schema lives. Under DOTNET_ENVIRONMENT=Docker, which compose.yaml sets, the default is Host=db instead. |
ConnectionStrings__Quartz |
Host=localhost |
Where Quartz's tables live - created only by containers/hatch-db/pginit.sql. Also Host=db under DOTNET_ENVIRONMENT=Docker. |
| Everything else | ||
HATCH_MIGRATE |
unset | 1 makes the image run as the migration, which applies the schema and exits, instead of serving. |
KioskFiles__BaseAddress |
http://files/ |
Where the kiosk app's signature checksum comes from. A board never calls it. |
DOMAIN |
unset | The host the kiosk app's download link is built against. A board never calls it. |
DOTNET_ENVIRONMENT |
Any value but Development writes the log as JSON lines instead of the human-formatted default. |
Auth__SignInPath cannot usefully move: the wall exempts the literal
/apps/auth (Services/Auth/AuthGate.cs:180), so pointing it elsewhere opens
a redirect loop. The wall's own off/on/required states are covered on
the wall, off, on and required.
A malformed Apps__AssetLinks entry stops the API at start
(Modules/AssetLinksValidation.cs), unlike Apps__PublicBaseUrl, which
degrades quietly.
The migration creates hatch's schema on every start; see
backup, restore and replication
for backing it up, restoring it and running it on a replica.
How to stop#
docker compose down
down keeps your data. The containers go; the volume outlives them, so
down and then up -d again — even after an upgrade has rebuilt the images —
brings back the same board. down takes the runner container with it whether or not you
name its profile, because it removes everything in the project.
docker compose down -v
down -v throws your data away. The -v removes the volume, and there is
no undo. Use it when you want to start over from an empty board, and not
otherwise.