Files
netris-nestri/docker-compose.yml
Wanjohi 9258c8dfef fix(deploy): make bun dev actually start, and sign-in actually work
Six defects found by running the thing rather than reading it. The
previous change was verified by bundling, by tests, and by the container
images — none of which start a Worker, so every one of these was invisible.

**`bun dev` did not start.** It ran one multi-config process, which does
not connect a service binding between the workers it loads; the API
reported `AUTH [not connected]` and could not verify a token. It is two
processes now, which is what the dev registry connects, and the second is
backgrounded with the first killed on exit so stopping the pair stops both.

**Neither server could bind.** Wrangler resolves `localhost` and takes
`::1` first; a host with no IPv6 address on its loopback dies with a bind
error from inside the runtime that names neither the app nor the port.
`dev.ip` is pinned to `127.0.0.1`, and `inspector_port` is now distinct per
app — it is not derived from the port above, so the second server to start
died on an address already in use.

**The API worker failed to evaluate.** A specifier ending in `.sql` is
claimed by the bundler as a module of its own, so the schema file was
emitted verbatim beside the bundle and the runtime threw on an export it
could not find. The route was reaching past the domain module into the
schema to spell a status; it now asks the domain module, which is the rule
everywhere else here and happens to also avoid the hazard.

**Signing in failed on the second request that touched the database.** A
pool is cached per connection string, and on a Worker an I/O object created
while handling one request may not be touched while handling another. The
first request always succeeded, which is why it went unnoticed — a sign-in
is several. The cache is now kept only where a process outlives its
requests, which is the case it was added for.

**The images named a base that podman will not resolve.** A short name
needs a registry; the database service alongside them already spelled one.

**Compose pinned container names.** The name is not scoped to the project,
so a second checkout got the same three, and `down` in one stopped the
other's containers. This is not hypothetical — it stopped a running
development database while this was being tested.

Verified by signing in end to end against both dev servers: a code
requested over HTTP, read from the issuer's log, redeemed, exchanged for
tokens, and presented to the API, which resolved it to the account the
sign-in had just created.
2026-09-05 16:31:04 +03:00

110 lines
4.9 KiB
YAML

# The whole control plane on one machine.
#
# Two uses, deliberately the same file. It is what a self-hoster runs, and it
# is the shape this deployment takes when it stops being a set of Workers: two
# stateless processes and a database, with a reverse proxy in front of them
# terminating TLS. Nothing here knows about a hosting provider.
#
# cp .env.example .env # then fill it in
# docker compose up --build everything, built from source
# docker compose up postgres just the database, for `bun dev`
#
# No service sets `container_name`. It reads like a nicety and behaves like a
# global: the name is not scoped to the project, so a second checkout of this
# repo — a worktree, a colleague's clone — gets the same three names, and
# `docker compose down` in one of them stops the containers belonging to the
# other. Compose names them per project on its own, which is what makes two
# checkouts independent.
#
# **There are no credentials in this file, and none of them have defaults.**
# Every one is read from `.env`, and compose refuses to start naming the
# variable it wanted rather than falling back to something. A default is worth
# less than it looks: the deployment that never set the variable is exactly the
# one where the default is a publicly known value, and `ADMIN_SHARED_SECRET`
# below bypasses authentication entirely.
#
# Migrations are not run for you — `bun run db:migrate` against DATABASE_URL,
# because a container that migrates on boot races with the second copy of
# itself and there is eventually a second copy.
x-postgres-url: &postgres-url
DATABASE_URL: postgres://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/${POSTGRES_DB:?set POSTGRES_DB in .env}
services:
postgres:
image: docker.io/postgres:18-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env}
# Loopback, not every interface. `5432:5432` would publish the database to
# anything that can reach this host. The three services below talk to each
# other over the compose network and do not use this mapping at all; it is
# here only so `bun dev` and `bun run db:migrate` can reach the database
# from outside a container.
ports:
- '127.0.0.1:5432:5432'
volumes:
- nestri_data:/var/lib/postgresql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
interval: 5s
timeout: 5s
retries: 10
auth:
build:
# The repository root, because the lockfile and the shared packages are
# there. Same reason for the API below.
context: .
dockerfile: apps/auth/Dockerfile
depends_on:
postgres:
condition: service_healthy
environment:
<<: *postgres-url
# Passed through rather than fixed here, so that setting them in `.env`
# is enough to make this deployment deliver mail. All three together or
# none of them: the issuer refuses to send when they are half configured.
EMAIL_SEND_URL: ${EMAIL_SEND_URL:-}
EMAIL_API_KEY: ${EMAIL_API_KEY:-}
EMAIL_FROM: ${EMAIL_FROM:-}
# Printing a live sign-in code to the log is a thing you ask for by name,
# and it is asked for in `.env` — not defaulted to here. With mail
# unconfigured and this unset, the issuer refuses to send rather than
# logging codes, which is the failure a self-hoster should get.
EMAIL_DEV_LOG: ${EMAIL_DEV_LOG:-}
# Loopback. This listener speaks plain HTTP and sets no `Secure` on the
# cookies it issues, because it expects to be behind something that
# terminates TLS. Published on every interface it would be a way to reach
# the issuer *around* that proxy, with codes and tokens in clear text.
ports:
- '127.0.0.1:1337:1337'
api:
build:
context: .
dockerfile: apps/api/Dockerfile
depends_on:
postgres:
condition: service_healthy
auth:
condition: service_started
environment:
<<: *postgres-url
# The issuer's public URL, and not `http://auth:1337`. A token carries
# the address it was minted through, and verification compares the two
# literally — so the name a browser used is the only one that can appear
# here. `AUTH_INTERNAL_URL` is how this container actually gets there.
AUTH_ISSUER_URL: ${AUTH_ISSUER_URL:?set AUTH_ISSUER_URL in .env}
AUTH_INTERNAL_URL: http://auth:1337
# A shared secret that turns any request carrying it into an operator.
# Required, with no default, for that reason.
ADMIN_SHARED_SECRET: ${ADMIN_SHARED_SECRET:?set ADMIN_SHARED_SECRET in .env to a value you generated}
# Loopback, for the same reason as the issuer above.
ports:
- '127.0.0.1:3000:3000'
volumes:
nestri_data: