Run it

Deployment

Everything needed to run this in earnest: the compose stack, the flags, TLS, backups, restore, and export. accept:6 checks this document against the binary — every flag named here exists, every image reference matches compose.yaml, and the required sections below are present — so it cannot drift into describing a program that is not this one.

On this page

Read the restore section before you need it. An untested restore is the single most likely way to lose this project’s data, which is why it has a drill and why phase 6 is the last phase.


Compose#

Compose rather than docker run, and that is not a convenience. Litestream is a second process and this image is distroless — no shell, no supervisor — so replication runs in a sidecar container sharing the data volume (A-5). One container cannot express that.

terminal
cp .env.example .env          # then edit it
docker compose up -d --build

“Then edit it” includes one line that is not optional. .env must set ROADMAP_SECURE_COOKIE to true (TLS terminating at a proxy in front of this stack — the usual answer) or false (plain HTTP). Neither .env.example nor compose.yaml picks one, so until it is set the roadmap container refuses to start and says so. See the cookie decision.

Two long-running services and one volume:

ServiceImageDoes
roadmapbuilt from this repositoryserves HTTP and MCP on 8080
litestreammirror.gcr.io/litestream/litestream:0.5.0replicates /data/roadmap.db to object storage

Both mount the same named volume at /data, and both run as uid 65532.

First run#

The stack comes up with an empty database and no accounts. The login page says so and names the command; here is that command for a compose deployment, where it runs inside the container rather than on the host:

terminal
docker compose exec roadmap roadmap admin create \
  --addr roadmap.example.com \
  --email you@example.com \
  --name 'Your Name'

It prints a single-use setup link, valid for seven days, and the token appears in that link and nowhere else — it is never logged, and only its SHA-256 is stored. Open the link to choose a password.

--addr is the address the server is reachable at, and it matters here. The command has no way to know: it runs inside the container, where the bind address is the wildcard 0.0.0.0:8080, and its default is loopback. Either produces a well-formed link with a real token pointing at the wrong machine. Pass the host you actually browse to — the command warns when you have not.

If TLS terminates at a reverse proxy, the scheme and host in the printed link are that proxy’s rather than what is printed. The path is what matters.

Losing the link before using it is recoverable: an existing administrator issues a fresh invite from the web UI, and a sole administrator who is locked out runs this command again for a different address.

Seeding the demo content#

An empty tracker is hard to evaluate, so the binary can fill a database with demo content — six projects, eleven milestones, seventy-eight issues with their events, comments, proposals and stale links, and eight accounts. It is a subcommand of the same binary rather than a script, because a release ships one binary and no scripts.

It goes on a stack of its own, and never on the deployment. Not a style preference — three separate things go wrong if you run it against the database the server is holding, and the command below is shaped to avoid all three:

terminal
# A project name of its own, so this is a SEPARATE VOLUME and a separate
# database. Seeding is not reversible: its rows go in through the operations
# real work goes through, so afterwards nothing can tell them apart, and the
# domain hard-deletes none of them. There is no --reset. Throw the stack away
# instead, with: docker compose -p roadmap-demo down -v
#
# `run --rm --no-deps`, not `exec`, and this matters. `exec` runs the seeder
# INSIDE the running server's container, which opens a second writer on a
# SQLite file the server holds at one connection while Litestream replicates
# it; a seed that loses that race leaves the database partly seeded. Nothing
# is serving this project yet, so nothing else has the file open.
#
# The entrypoint override is needed for the reason `--version` needs one: the
# image's entrypoint is the server with its flags already applied, so without
# it these arguments are appended to a server command line.
docker compose -p roadmap-demo run --rm --no-deps --entrypoint /roadmap \
  roadmap demo seed \
  --db /data/roadmap.db \
  --addr localhost:8081

# Then serve what was just seeded. ROADMAP_SECURE_COOKIE=false is the answer
# the refusal below asks for, and it is the right one HERE and nowhere else:
# this is plain HTTP on loopback. A demo stack is not a template for the
# deployment.
ROADMAP_PORT=127.0.0.1:8081 ROADMAP_SECURE_COOKIE=false \
  docker compose -p roadmap-demo up -d

The third hazard is the administrator. The account the demo is seeded as belongs to the demo, not to you, and it will not exist on your instance — so on a database without it the command CREATES one, as an administrator, and prints a live setup link for it. That is correct on a throwaway stack and is a new privileged account with a working way in on anything else.

It ends by printing that link, the same single-use link admin create prints, for the administrator the demo belongs to. That is the only way in it produces: the demo accounts get an invite row and never a password, so without that link the result is a full tracker nobody can open. Pass the reachable address for the reason the first-run command does. If the seeding fails after creating that account, the link is still printed — the account is still there, and that link is the only one it will ever have.

There is no default for the seeder’s database path, for the same reason: the only sensible default would be the one the server opens.

Two more flags, both optional. –actor is the email of the administrator the demo is seeded as, which is also “me” in its saved views; the command creates that account when the database has nobody by that address, and seeds as the existing one when it does — in which case it prints no link, because that account already has a credential. –now is the instant the demo’s timeline is dated from, as RFC 3339, so “2h ago” lands somewhere fixed; it defaults to the wall clock.

Seeding is idempotent by natural key and converges rather than resetting: running it twice creates nothing the first run created. There is no reset, because the domain cannot express one — a project is archived rather than deleted and keeps its key. A fresh database path is the reset.

The seeder refuses a database somebody is using. Before it writes anything it takes a census — every account, project and issue the plan does not own by natural key — and stops if it finds one, naming what it found. An empty database and a re-seed of the demo’s own both pass, which is the whole set of databases it is for. The check is a backstop and not a permission slip: a database holding nothing yet looks exactly like a throwaway one, so a brand new production instance is seeded without complaint. Point –db at a path you can delete.

The registry prefix#

Third-party images are written mirror.gcr.io/…, Google’s read-through mirror of Docker Hub, because the network this was built on refuses Docker Hub’s blob CDN. It is the same content under the same digests. With unrestricted egress you can replace the prefix with docker.io/ and the digests do not change.

The images in compose.minio.yaml are the exception in the other direction: MinIO’s repositories are gone from Docker Hub and quay.io has disabled anonymous pull for the minio namespace (D-060, D-082), so the drill uses LocalStack — an S3-compatible server still available on Docker Hub. Both images use the same mirror.gcr.io prefix as everything else.

Every image is pinned by digest#

A tag is a pointer and these tags are somebody else’s to move. accept:6 asserts that every third-party image reference in compose.yaml carries both a tag and a sha256. Our own image is pinned by being built from this repository; set ROADMAP_IMAGE to a published tag and digest to run a release instead. .env.example carries a working value to copy.

:latest tracks releases, not prereleases. A tag carrying a hyphen - v0.0.1-rc1, v0.0.1-phase0 - publishes under its own name and nothing else, so docker pull with no tag never hands somebody a release candidate they did not ask for. Running a prerelease is possible and deliberate: name it.

Stopping, and why the order matters#

terminal
docker compose down        # keeps the volume
docker compose down -v     # DELETES THE VOLUME, and with it the local database

down -v deletes the entire local database. The replica is what survives it.

The roadmap service declares depends_on: [litestream], and compose stops services in reverse dependency order — so roadmap stops first and litestream second. That is deliberate and it is D-029’s requirement: Litestream must outlive the server, or the last frames the server wrote never ship.

stop_grace_period is 90s, and it must stay above --drain-timeout plus --replication-timeout (15s + 30s). If it drops below, Docker sends SIGKILL in the middle of the shutdown sequence and the whole thing is decoration. accept:6 checks that arithmetic.

Building behind a TLS-inspecting proxy#

If docker compose build fails with x509: certificate signed by unknown authority, the build container does not trust your network’s certificate:

terminal
docker build --secret id=ca,src=/etc/ssl/certs/your-ca.crt -t roadmap:local .

The certificate is mounted as a build secret and never becomes a layer.


Configuration#

Every flag can also be an environment variable. Precedence is flag, then environment, then the default.

FlagEnvironmentDefaultWhat it does
--addrROADMAP_ADDR127.0.0.1:8080listen address
--publicROADMAP_PUBLICoffpermits a non-loopback bind. See below
--db—roadmap.dbpath to the SQLite file
--secure-cookieROADMAP_SECURE_COOKIEderived; no default with --public, where one of the two is requiredforces the session cookie’s Secure attribute
--trusted-originROADMAP_TRUSTED_ORIGINSnonean origin allowed to make non-safe requests. Repeatable
--litestream-urlROADMAP_LITESTREAM_URLnonethe sidecar’s metrics endpoint; enables the shutdown wait
--litestream-db—--dbthe database path as the sidecar labels it
--drain-timeout—15show long to wait for in-flight requests
--replication-timeout—30show long to wait for replication before giving up loudly
--devROADMAP_DEVoffserves the development-only surface. See below
--version——print the version and exit; opens no database and binds no port

--dev adds a route and is off on a deployed host. It serves the design system page at /design, which renders every component the application draws so a change can be checked against the whole inventory rather than one page. Routes() appends it only when the flag is set, so a released binary started without it has no such route to reach — there is nothing to protect because there is nothing there. Development only; scripts/dev.sh passes it.

--version is how you find out what a host is running. Released binaries are stamped by goreleaser from the tag; a go build reports dev. The image does not expose it through the entrypoint, so reach it with docker compose run --rm --entrypoint /roadmap roadmap --version.

One environment variable is Go’s rather than ours, and it is set in compose.yaml deliberately:

VariableDefaultWhat it does
GOMEMLIMIT96MiBa soft memory ceiling the Go runtime keeps itself under by collecting more often

PRD.md budgets 150 MB at 50 concurrent users (D-079), and without a limit the runtime has no reason to respect it: measured peak RSS across three identical load runs was 84, 88 and 126 MB, because how much a Go heap grows before a collection depends on how busy the machine is. A soft limit turns that into a property rather than a coincidence. It is soft — the runtime will exceed it rather than fail an allocation — so the failure mode is more CPU, not an OOM. Raise it if you have the memory and want the throughput.

Litestream’s own configuration is environment-driven through litestream.yml. All six are required for replication to work:

VariableExample
LITESTREAM_BUCKETroadmap-backups
LITESTREAM_PATHproduction/roadmap.db
LITESTREAM_ENDPOINThttps://s3.eu-central-1.amazonaws.com, or your provider’s
LITESTREAM_REGIONeu-central-1
LITESTREAM_FORCE_PATH_STYLEtrue for MinIO and most self-hosted S3; false for S3
LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEYthe credentials

LITESTREAM_PATH must be unique per deployment. Litestream keys its generations by that prefix, and two stacks sharing one corrupt each other’s replicas rather than colliding visibly.


The bind flag#

The server binds 127.0.0.1 by default and refuses to bind anything else without --public (D-022):

text
bind refused: refusing to bind 0.0.0.0:8080: that address is reachable from
other machines, and this server listens on loopback unless told otherwise.
Pass --public (or set ROADMAP_PUBLIC=1) if that is what you want …

It refuses rather than warning, because a warning on stderr that the process ignores is not a control. A self-hosted tool that listens on every interface out of the box is how instances end up reachable from the internet by accident; the person who genuinely wants a public bind can pass a flag and has thought about it.

The check reads the resolved address, so ROADMAP_ADDR=0.0.0.0:8080 is refused exactly as the flag form is. :8080 counts as public: an empty host means every interface, which is the form most likely to be typed by someone who means localhost.

Inside the container the flag is set, and that is correct: the container boundary is the isolation, and the compose file maps the port to 127.0.0.1 on the host. Serve it publicly with a reverse proxy, not by changing that mapping.


TLS at a reverse proxy#

TLS terminates at a reverse proxy. There is no in-process certificate handling and there will not be (D-022): certificate renewal, OCSP and cipher policy are solved problems in the proxy layer.

Caddy, which gets certificates on its own and redirects port 80 to 443 without being asked:

caddy
roadmap.example {
    reverse_proxy 127.0.0.1:8080

    # Six months, and only once you are sure TLS is staying. A browser that has
    # seen this will refuse plain HTTP to this host for that long, and there is
    # no way to call it back early.
    header Strict-Transport-Security "max-age=15768000"
}

nginx, where the port-80 server is not optional — without it a browser that reaches http://roadmap.example is served nothing and nothing is redirected, and the first request a person makes from their address bar has no scheme on it:

nginx
# Every plain-HTTP request, redirected before it can carry a session cookie.
server {
    listen 80;
    server_name roadmap.example;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name roadmap.example;

    ssl_certificate     /etc/letsencrypt/live/roadmap.example/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/roadmap.example/privkey.pem;

    # The redirect above still leaves one plain request on the wire. HSTS is
    # what stops the SECOND one: the browser rewrites http:// to https://
    # itself, before anything is sent. Six months, and only once you are sure -
    # it cannot be withdrawn early.
    add_header Strict-Transport-Security "max-age=15768000" always;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Origin            $http_origin;

        # The event stream (SPEC-25). nginx buffers a proxied response by
        # default, which holds every frame until the stream ends - so live
        # updates work locally, work behind Caddy, and silently do not work
        # here. The application sends X-Accel-Buffering: no, which nginx
        # honours; these two are belt and braces, and the read timeout matters
        # because a stream is idle between changes for longer than the 60s
        # default.
        proxy_buffering             off;
        proxy_read_timeout          1h;
        proxy_http_version          1.1;
    }
}

If tabs stop updating behind a proxy, this is why. The symptom is a page that is correct when loaded and never changes again, with nothing in the log: the stream is open and its frames are sitting in the proxy. The same class of surprise AddTrustedOrigin covers below, and it is worth checking first because nothing fails - a buffered stream and a working one look identical until somebody else changes something.

A server started with --public refuses to start unless ROADMAP_SECURE_COOKIE is set to true or false. There are two valid answers and the server will not pick one for you:

text
secure-cookie: refusing to start: --public says this server is reachable from
other machines, and nothing has decided whether the session cookie is marked
Secure … Set ROADMAP_SECURE_COOKIE=true (or pass --secure-cookie=true) when TLS
is in front of this server, which is the usual answer; set
ROADMAP_SECURE_COOKIE=false only if you mean to serve plain HTTP …
  • true — TLS terminates at a proxy in front of this server. This is the answer for every deployment in this guide.
  • false — you mean to serve plain HTTP, and accept that the session cookie travels in the clear to anyone on the path.

The refusal exists because the default is wrong in the quietest possible way. The Secure attribute is otherwise derived from the bind address (D-035), and the default bind is 127.0.0.1:8080 — which is also exactly the address a reverse proxy forwards to. Derivation reads that as loopback, turns Secure off, and a TLS-terminated instance then sends its session cookie unprotected on the first plain-HTTP hop anyone makes. Only the container escaped it, because it binds 0.0.0.0.

It cannot be decided per request instead: scs reads the setting once per cookie write rather than per request, and r.TLS is nil at exactly the moment it matters. The remaining per-request signal is X-Forwarded-Proto, which is attacker-controlled without a trusted-proxy configuration this project does not have.

A loopback bind without --public is unchanged — derivation still applies, so local development needs none of this.

A compose deployment does not escape this, and must not. compose.yaml passes the variable through with no default, and .env.example ships it commented out, so docker compose up stops with the same refusal until .env carries a decision: ROADMAP_SECURE_COOKIE=true with TLS in front of the stack, ROADMAP_SECURE_COOKIE=false to serve plain HTTP. It used to default to false there, which answered the question for you — and an operator who put TLS in front still shipped a cookie without Secure and never saw this section.


AddTrustedOrigin#

Symptom: every form submission answers 403 “Request blocked” after you put a reverse proxy in front of the app. This is the single most likely thing to go wrong on a first TLS deployment, and it is not a bug.

Cross-site request forgery protection is net/http’s CrossOriginProtection (D-027). It reads Sec-Fetch-Site, falling back to comparing the request’s Origin against its Host. A reverse proxy that rewrites Host — to the internal name, which nginx does by default unless told otherwise — leaves the browser’s Origin of https://roadmap.example next to a Host of roadmap:8080. They do not match, so the request is refused.

Two fixes, and you want both:

  1. Tell the proxy to preserve the original Host (proxy_set_header Host $host; above, which Caddy does by default).
  2. Tell the server which origins to trust, which covers the case where the proxy cannot preserve it:
yaml
    command:
      - --trusted-origin=https://roadmap.example

or ROADMAP_TRUSTED_ORIGINS=https://roadmap.example,https://www.roadmap.example.

An origin is a scheme and a host with no path — https://roadmap.example, not https://roadmap.example/. The server refuses to start on a malformed one, rather than accepting it and failing at the first cross-origin request when nobody is watching.


The REST API#

/api/v1 is on by default and needs no configuration: it authenticates with the same tokens as MCP, personal or OAuth (docs/API.md). Two variables tune it. ROADMAP_API_RATE_PER_MINUTE is each token’s allowance, 600 by default, and -1 turns the limit off; past it a caller gets 429 with Retry-After. ROADMAP_API_CORS_ORIGINS is a comma-separated list of browser origins allowed to call it, empty by default; scripts and servers never need it.

OAuth sign-in#

MCP clients can sign in with OAuth instead of a pasted token (SPEC-57, D-114). claude.ai and other web chat apps need it, because their custom connectors take only a URL. The server is its own authorization server: a person signs in with their usual email and password and allows the app on a consent page. No identity provider is involved.

VariableDefaultWhat it does
ROADMAP_BASE_URLnonethe address people browse to, such as https://roadmap.example.com. Required for OAuth: it is the issuer, and must be stable and public
ROADMAP_OAUTHonoff turns OAuth sign-in off. Personal tokens keep working either way

Without ROADMAP_BASE_URL, the discovery routes answer 404 with a sentence naming the variable, a 401 from /mcp doesn’t point at them, and personal tokens work as before.

What it serves, all on the same port:

  • /.well-known/oauth-protected-resource/mcp and /.well-known/oauth-authorization-server: discovery (RFC 9728, RFC 8414).
  • /oauth/register: open client registration (RFC 7591), for public clients only. A registered client can do nothing until a person allows it. It’s limited to 30 registrations an hour from one address, and at most 500 clients nobody has allowed are kept; those are deleted after a day. Behind a reverse proxy every request has the proxy’s address, so the hourly limit is then for the whole server.
  • /oauth/authorize: the consent page in the web UI.
  • /oauth/token and /oauth/revoke.

An access token lasts an hour, and a refresh token 30 days, renewed on each use. PKCE with S256 is required. A connection is listed in Settings → Access tokens next to personal tokens, and revoking it there disconnects the app. The reverse proxy needs nothing extra: the routes are ordinary paths on the server’s address.

The healthcheck#

roadmap healthcheck performs a GET /healthz against the resolved address and exits 0 on a 200. It exists because a distroless image has no curl and no shell, so the image’s HEALTHCHECK has nothing else to run, and it is wired into the Dockerfile.

terminal
docker compose exec roadmap /roadmap healthcheck   # inside
docker compose ps                                   # or just read the health column

It opens no database. That matters: the pool is SetMaxOpenConns(1), so a health check that opened it would compete with the requests it is meant to be measuring, every 30 seconds, for the life of the container.


Backups#

Litestream streams the WAL to object storage continuously. There is no cron job and no dump step.

What makes this a backup rather than a hope is the shutdown sequence. On SIGTERM the server drains in-flight requests, then waits for the sidecar to confirm it has caught up, then checkpoints the WAL, then closes (D-029, D-038). Without that wait, a server that exits before the sidecar has shipped its last frames passes a local drill against a co-located MinIO and loses the last writes against real object storage.

Two things to know from the logs:

  • shutdown step step=checkpoint … the WAL could not be truncated because Litestream holds a read lock at WARN is normal and expected on every shutdown with the sidecar running. Litestream holds a read transaction by design so the application cannot checkpoint away frames it has not shipped. Replication was confirmed by the step before it, so nothing is lost.
  • gave up waiting for Litestream to confirm replication at ERROR is not normal. Writes made shortly before that shutdown may not be in object storage. Check the sidecar.

Set a lifecycle rule on the bucket. Litestream accumulates snapshots and nothing in this stack prunes them; a bucket that grows for ever is a bill. Scope the rule to LITESTREAM_PATH and never to the whole bucket: attachments live in the same bucket under ROADMAP_ATTACHMENTS_PREFIX (default attachments/), and an expire-after-N-days rule that covers them deletes people’s files silently (D-057, SPEC-55).

Attachments#

Files on issues and comments are stored in the replication bucket, on the same six LITESTREAM_* values, under ROADMAP_ATTACHMENTS_PREFIX (default attachments). With LITESTREAM_BUCKET unset the feature is off: the upload controls are hidden, attachment_add says which variable turns it on, and nothing else changes. The server refuses to start when the prefix is empty or overlaps LITESTREAM_PATH, since a restore could then read a file as a replica. Each file is at most 10 MB from the web and 2 MB inline through MCP; an agent sends a larger one by PUTting it to a one-time link from attachment_upload_url (/uploads/{token}). If a reverse proxy sits in front, let PUT /uploads/ bodies of 10 MB through, and set ROADMAP_BASE_URL so the links name the public address.

A restore brings back the rows and leaves the objects where they are, so a restored server serves the same files. A deleted attachment’s object is removed at once; its row and its history event stay.

HTML and SVG files are served under Content-Security-Policy: sandbox, so their scripts run in an opaque origin, whether previewed or opened in a tab, and cannot read the session cookie or act as the viewer.


Restore#

Tested, by accept:6, on every run — it kills a server, deletes the volume, restores from object storage and requires the issue count and the last event row to match exactly.

terminal
mkdir -p restore
docker compose --profile restore run --rm restore

That writes restore/roadmap.db, reconstructed from object storage alone. It needs no running sidecar and reads nothing from the live volume.

To put it back:

terminal
docker compose down                     # stop both, in the right order
docker volume rm roadmap_data           # the damaged one
docker compose up -d roadmap            # recreates the volume, empty
docker compose cp restore/roadmap.db roadmap:/data/roadmap.db
docker compose restart roadmap
docker compose up -d                    # bring the sidecar back

Restore refuses to overwrite an existing file. That is the behaviour worth having: restore is what you reach for at the worst possible moment, and “it overwrote the database that was fine” is not a recoverable mistake. Always restore into a scratch directory and move the result.

After a restore, Litestream starts a new generation. That is expected.


Export#

Copying the SQLite file is the export story (J-11). That is a real answer for this architecture — one file, no server-side format, readable by anything that speaks SQLite — but only if the whole instruction is written down, and the whole instruction is not “copy roadmap.db”.

While the stack is running, roadmap.db is stale. Committed transactions live in the write-ahead log until a checkpoint moves them, and Litestream holds a read lock that stops the application truncating the WAL — so a copy of the .db alone is consistent and missing everything since the sidecar’s last passive checkpoint.

Two correct forms:

1. Stop the stack and copy all three files.

terminal
docker compose down
docker run --rm -v roadmap_data:/data -v "$PWD":/out alpine \
    cp /data/roadmap.db /data/roadmap.db-wal /data/roadmap.db-shm /out/

roadmap.db, roadmap.db-wal and roadmap.db-shm — three files. Leaving out the -wal is the mistake this section exists to prevent. (-shm is regenerable, but copying it costs nothing and keeping the set together avoids the question.)

2. Restore from the replica into a scratch directory, exactly as above. This one does not touch production at all, produces a single self-contained file, and has the useful property that your export path is your disaster-recovery path — so it is exercised rather than assumed.

Then open it with anything:

terminal
sqlite3 roadmap.db "SELECT key, title, status FROM issue WHERE deleted_at IS NULL"

There is no import path from another tracker in the binary, and there is not going to be one — SPEC-32 has the argument. Importing from Linear is a recipe for an agent session with two MCP servers connected, written up at docs/guides/importing-from-linear.md.


Litestream configuration#

litestream.yml is mounted read-only into the sidecar and expands ${VAR} from the environment before parsing, so one file serves both this deployment and the drill’s MinIO. The drill changes the environment, not the configuration — which is what makes accept:6 a test of the deployment rather than of a fixture.

sync-interval is one second. A short interval is not the control that protects recent writes; it shrinks the window and never closes it. What closes it is the server waiting for the sidecar before it exits.

To try the whole thing against local object storage before trusting it with real data:

terminal
docker compose -f compose.yaml -f compose.minio.yaml up -d

That adds two more images, both pinned by tag and digest like everything else:

ImageDoes
mirror.gcr.io/localstack/localstack:3.7.0the object storage (S3-compatible)
mirror.gcr.io/amazon/aws-cli:2.27.0a one-shot that creates the bucket

The second one exists because Litestream does not create buckets, and a missing one presents as a replication error rather than as a missing bucket. The drill uses LocalStack rather than MinIO because MinIO’s images are no longer pullable without authentication (D-060, D-082); LocalStack’s S3 API is the same one Litestream speaks to.

It is the same stack accept:6 drills, which is the point: the drill adds object storage to the shipped compose file rather than replacing it, so what is verified is what ships.

LITESTREAM_PATH matters here too. The drill gives every run a unique prefix, and it does so because a previous run’s replica was once restored into a later run — the issue counts matched, so every count assertion passed, and the only thing that caught it was an event row’s timestamp being two minutes old.


What this deployment does not do#

Recorded so they read as decisions rather than gaps.

  • No rate limiting on login. A reverse proxy is where it belongs, and argon2id at ~130 ms per attempt is itself a brake.
  • No password reset without an administrator, unless mail is configured. Set ROADMAP_MAIL and ROADMAP_BASE_URL and the login page offers a reset link; leave them unset and a locked-out user needs an admin to issue a fresh invite. A locked-out sole admin needs roadmap admin create on the host either way - a mail transport does not help somebody who is the only account.
  • No multi-origin serving. D-027’s protection assumes one origin. If you ever serve this from two, that decision needs revisiting first.
  • No clustering. One process writes (D-002). Scale is vertical.