Host it for your team
Two commands put the production image on a server. Read the whole page before the last one, because the default binding is deliberately local-only.
-
Set a database password.
Terminal window echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .envCompose refuses to start without it —
${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}. -
Start it.
Terminal window docker compose -f docker-compose.prod.yml up -dThis pulls
jaklimoff/ushabti, applies the migrations at start and serves on127.0.0.1:3000— bound to loopback, so nothing reaches it until you put a proxy in front. -
Put TLS in front of it. In production the session cookie is marked
secure, so a browser will not send it back over plainhttp. Without a proxy, nobody can stay signed in. -
Close sign-up once your team has registered.
Terminal window echo "USHABTI_SIGNUP=closed" >> .envdocker compose -f docker-compose.prod.yml up -d -
Optional: let the server email invites and reset links. Give it an SMTP server and a sender:
Terminal window echo "SMTP_URL=smtps://user:password@smtp.example.com:465" >> .envecho 'MAIL_FROM="Ushabti <board@example.com>"' >> .envdocker compose -f docker-compose.prod.yml up -dWithout them nothing is emailed, and the owner sends the link by hand. See
SMTP_URLandMAIL_FROM.Add the board’s public address as well, and the sign-in page offers Forgot password?, so a person can get a reset link by email without asking anybody:
Terminal window echo "USHABTI_URL=https://tasks.example.com" >> .envdocker compose -f docker-compose.prod.yml up -dThe emailed link is built from this address and never from the request. See
USHABTI_URL.
Which image, which tag
Section titled “Which image, which tag”The same image is published to Docker Hub and to ghcr.io/jaklimoff/ushabti, built from the same
commit by the same workflow. Pick either.
| Tag | What it is | Use it for |
|---|---|---|
:edge |
main, rebuilt each night |
Trying things. Not production. |
0.5.0 |
A release | Production. Pin it. |
Pin a release by setting USHABTI_VERSION in .env:
USHABTI_VERSION=0.5.0Add --build to the compose command to build the image from your own checkout instead of pulling.
Upgrading
Section titled “Upgrading”docker compose -f docker-compose.prod.yml pulldocker compose -f docker-compose.prod.yml up -dThe container’s command is node scripts/migrate.mjs && node server.js, so every start applies
any migration the database has not seen. There is no separate migration step to remember.
Check the changelog before a jump: some releases carry a note. The 0.3.0 upgrade, for example, closes any open run whose agent last reported over thirty minutes ago the first time somebody loads the board.
Checking it is up
Section titled “Checking it is up”GET /api/health answers 200 while the app can reach its database and 503 when it cannot.
It needs no session and no token, and the answer is only {"ok":true}, so point Uptime Kuma, a
load balancer or a Kubernetes probe at it:
curl -fsS http://127.0.0.1:3000/api/healthThe app service in docker-compose.prod.yml has a healthcheck that asks it, so
docker compose -f docker-compose.prod.yml ps shows the app as healthy or unhealthy.
A database you already have
Section titled “A database you already have”Point DATABASE_URL at it and drop the db service. Two things about managed clusters:
node-postgres verifies the certificate when the URL says sslmode=require. That is stricter
than libpq and than most other drivers, so a URL that works elsewhere can fail here. Give it the
provider’s CA:
DATABASE_URL="postgres://user:pass@host:5432/ushabti?sslmode=verify-full&sslrootcert=/etc/ssl/ca.crt"DATABASE_POOL_MAX=6One Ushabti process otherwise holds up to twelve connections, and takes one more per open
browser tab for live updates — every SSE stream keeps a LISTEN connection outside the pool.
DigitalOcean’s smallest managed cluster allows 25 connections for everything.
What the image is
Section titled “What the image is”A three-stage node:24-bookworm-slim build using Next’s output: "standalone", so the runner
installs nothing at start.
Backing it up
Section titled “Backing it up”The board is in Postgres. The files people attach to a task are not: they live in the S3 bucket
named by S3_BUCKET, and the database holds only their names and keys. A dump alone brings back
every task with its files missing, so back up both.
The database:
docker compose -f docker-compose.prod.yml exec db \ pg_dump -U ushabti ushabti | gzip > ushabti-$(date +%F).sql.gzThe bucket, if you set one. Copy it to a second bucket or a disk, with any S3 tool. Take it after the dump, so every file the dump names is already there:
aws s3 sync s3://ushabti-files ./ushabti-files-$(date +%F) --endpoint-url "$S3_ENDPOINT"Versioning on the bucket, where your provider has it, also keeps a file somebody removed.
A task you archive keeps everything and can be put back. A deleted task can be put back for 30 days, from Deleted, gone in 30 days. After that it is gone, and the backup is the only way back.
A backup nobody has restored is a guess, so try one into an empty database before you need it:
docker compose -f docker-compose.prod.yml exec db createdb -U ushabti ushabti_restoregunzip -c ushabti-2026-09-20.sql.gz | docker compose -f docker-compose.prod.yml exec -T db \ psql -U ushabti -d ushabti_restore -v ON_ERROR_STOP=1 -qThen count what came back — select count(*) from tasks — against the live database, and drop
the scratch one.
The Export row on Settings → Project is not this. It saves one project as a JSON file an owner or
an admin can read and take away, without shell access. It leaves out the activity feed, the runs
and every account’s password, so it cannot bring an instance back. pg_dump stays the backup of
the whole instance.