Skip to content

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.

  1. Set a database password.

    Terminal window
    echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> .env

    Compose refuses to start without it — ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}.

  2. Start it.

    Terminal window
    docker compose -f docker-compose.prod.yml up -d

    This pulls jaklimoff/ushabti, applies the migrations at start and serves on 127.0.0.1:3000 — bound to loopback, so nothing reaches it until you put a proxy in front.

  3. Put TLS in front of it. In production the session cookie is marked secure, so a browser will not send it back over plain http. Without a proxy, nobody can stay signed in.

  4. Close sign-up once your team has registered.

    Terminal window
    echo "USHABTI_SIGNUP=closed" >> .env
    docker compose -f docker-compose.prod.yml up -d
  5. 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" >> .env
    echo 'MAIL_FROM="Ushabti <board@example.com>"' >> .env
    docker compose -f docker-compose.prod.yml up -d

    Without them nothing is emailed, and the owner sends the link by hand. See SMTP_URL and MAIL_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" >> .env
    docker compose -f docker-compose.prod.yml up -d

    The emailed link is built from this address and never from the request. See USHABTI_URL.

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:

Terminal window
USHABTI_VERSION=0.5.0

Add --build to the compose command to build the image from your own checkout instead of pulling.

Terminal window
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d

The 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.

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:

Terminal window
curl -fsS http://127.0.0.1:3000/api/health

The 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.

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:

Terminal window
DATABASE_URL="postgres://user:pass@host:5432/ushabti?sslmode=verify-full&sslrootcert=/etc/ssl/ca.crt"

A three-stage node:24-bookworm-slim build using Next’s output: "standalone", so the runner installs nothing at start.

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:

Terminal window
docker compose -f docker-compose.prod.yml exec db \
pg_dump -U ushabti ushabti | gzip > ushabti-$(date +%F).sql.gz

The 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:

Terminal window
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:

Terminal window
docker compose -f docker-compose.prod.yml exec db createdb -U ushabti ushabti_restore
gunzip -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 -q

Then 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.