Skip to content

Deploying ​

Two ways to run the whole mall: docker compose on any machine, or Railway (what the demo uses, see ADR 0005). Both run the same images.

PieceImageComposeRailway
web: nginx serving the built site, proxying /ws, /api, /files, /health, /host-token, /directory/ to the serverclient/Dockerfilewebservice web (public domain)
server: multiplayer, content API, uploadsserver/Dockerfileserverservice server (private only)
Postgresstockpostgres:17Railway Postgres template (18)
Object storageS3-compatibleSeaweedFSRailway bucket uploads
Daily backuptools/backup/Dockerfilerun by handservice backup, cron 0 3 * * *

Settings ​

server

VariableWhat
DATABASE_URLPostgres. Migrations run on start, and an empty database is seeded from mall.config.ts.
S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGIONUploads bucket.
S3_URL_STYLEvirtual for Railway and AWS (bucket in the hostname). Leave unset for SeaweedFS and MinIO.
HOST_SECRETThe host password: signs in on the landing screen and at /admin/. Unset means no host and no admin.
PORTDefault 8787.
ROOM_CAPACITY, REPORT_WEBHOOK, BLOCKLIST_FILE, METRICSOptional, see .env.example.

web

VariableWhat
PORTPort nginx listens on (default 80).
API_UPSTREAMhost:port of the server (default server:8787). It's looked up per request, so the web can start before the server.

backup: DATABASE_URL, the same S3_* as the server, KEEP (dumps to keep, default 30), and the build argument PG_MAJOR, which must be at least the database's major version.

Railway ​

The production project shopping-mall (https://web-production-cc219.up.railway.app) runs in Singapore (asia-southeast1, bucket region sin). Every service builds from main on GitHub. Watch paths keep a docs-only push from rebuilding anything.

ServiceDockerfile (RAILWAY_DOCKERFILE_PATH)Notable settings
webclient/DockerfilePublic domain on port 8080, PORT=8080, API_UPSTREAM=${{server.RAILWAY_PRIVATE_DOMAIN}}:8787, health check /
serverserver/DockerfilePORT=8787, DATABASE_URL=${{Postgres.DATABASE_URL}}, S3_*=${{uploads.*}}, S3_URL_STYLE=virtual, HOST_SECRET, health check /health
backuptools/backup/DockerfileCron 0 3 * * * (UTC), restart never, PG_MAJOR=18, same database and bucket references
PostgresRailway templateVolume postgres-volume
uploadsRailway bucketPrivate. Files reach visitors through the server's /files/ route.

.railway/railway.ts describes the same project as code (Railway IaC). It keeps the one secret, HOST_SECRET, out of the repo with preserve(). To compare it with the live project, install the SDK next to it (npm install railway, left out of the repo's dependencies) and run railway link, then railway config plan (Railway CLI 5 or newer, or npx @railway/cli@latest). A whole-project apply deletes anything the file leaves out, so read the plan first.

One click: Deploy on Railway. The template creates all five pieces with the variables below and asks you only for HOST_SECRET.

By hand: create a project with the Postgres template and a bucket named uploads, add three empty services named as above, set the variables in the table, connect the repo, and give web a domain. Or edit .railway/railway.ts and run railway config apply.

Backups and restoring ​

The backup service runs pg_dump --format=custom every night and uploads it to the bucket as backups/mall-<UTC time>.dump, keeping the newest 30. The server never serves these files (/files/ only serves <kind>/<sha256>.<ext>). Uploaded files themselves are not in the dump; they're already in the bucket, named by their content hash.

To run a backup now on Railway, open the backup service and choose Run now, or redeploy it.

To restore, run the same image's restore.sh with the database and bucket settings. It downloads the dump and replaces every table in it, in one transaction.

bash
docker build -f tools/backup/Dockerfile --build-arg PG_MAJOR=18 -t shopping-mall-backup .
# restore.env: DATABASE_URL (Railway: the Postgres service's DATABASE_PUBLIC_URL) and the S3_* values
docker run --rm --env-file restore.env shopping-mall-backup /usr/local/bin/restore.sh latest
# or a specific one: … restore.sh mall-2026-09-29T030000Z.dump

Then restart the server service, so visitors reconnect and pick up the restored content.

Tested on 2026-09-29. Locally against compose: back up, delete a shop, restore, and the shop and its products were back, and KEEP pruned older dumps. On Railway: the cron job wrote backups/mall-2026-09-28T202057Z.dump to the bucket.

docker compose ​

bash
cp .env.example .env        # set HOST_SECRET to use /admin/
docker compose up --build   # → http://localhost:8080 (WEB_PORT to change)

Postgres data and uploads live in the pgdata and s3data volumes. To back up the database: docker compose exec postgres pg_dump -U mall -Fc mall > mall.dump, and to restore it: docker compose exec -T postgres pg_restore -U mall -d mall --clean --if-exists < mall.dump.

MIT licensed code. Assets CC BY 4.0 unless noted.