Self-hosting TCKDB
This guide describes how to run a TCKDB instance on a machine you control — a cloud VM, a lab server, or a single-board computer (e.g. a Raspberry Pi). It covers the two supported deployment shapes, when to pick each, and the cross-cutting good practices (exposure, secrets, backups, migrations).
For applying schema migrations to an already-running database, see the
companion backend/docs/deployment/migrations.md
runbook — everything below assumes that flow for schema changes.
The pieces
A TCKDB deployment has four moving parts:
| Piece | What it is | How it's usually run |
|---|---|---|
| PostgreSQL (RDKit) | the database | a container (stateful, pinned image) |
| S3 object store | artifact object store (SeaweedFS by default; any S3-compatible service) | a container, or a hosted service such as AWS S3 |
| API | the FastAPI app (uvicorn app.api.app:create_app --factory) |
container or native process |
| Ingress | public TLS + hostname | a Cloudflare tunnel or reverse proxy |
The database and object store are almost always containers — that's the easy, uncontroversial part. The real choice is how you run the API.
Choosing a shape
Pattern A — Fully containerized (recommended default)
Everything (DB, object store, API, worker) is a container; docker compose up brings
the whole stack up. The API runs from a published image
(docker.io/laxzal/tckdb-api, built by
.github/workflows/build-api-image.yml).
- Pros: maximum reproducibility (the image pins Python + every dependency), one mental model, trivial to move to another host.
- Cons: you need to build/pull the API image. On arm64 (a Raspberry Pi), building that image is slow — see the arm64 note below.
- Use when: you're on an amd64 cloud VM or lab server, or you're willing to build/pull an arm64 image on ARM hardware.
Pattern B — Native API + containerized infra
DB and object store are containers; the API runs as a native process under systemd, from a conda/micromamba environment. Ingress via a Cloudflare tunnel.
- Pros: no API image to build — the scientific stack (RDKit, cantera) installs directly from conda-forge as prebuilt binaries, which is much smoother on arm64.
- Cons: the environment is a "snowflake" (built per-host), so it's less perfectly reproducible than an image.
- Use when: you're on constrained/ARM hardware (Raspberry Pi) where building a container image for the scientific stack is painful.
The arm64 note (why Pattern B exists)
An arm64 CPU (Raspberry Pi, Apple Silicon, AWS Graviton) cannot run an amd64 image natively — different instruction sets. It only works through an emulation layer (QEMU on Linux, Rosetta on macOS), which is significantly slower and more memory-hungry — fine for a quick test, wrong for a long-running server. So on arm64 you want a native arm64 image, and building one on an amd64 CI runner requires emulating arm64 (slow). Pattern B sidesteps the build entirely by installing conda-forge's prebuilt arm64 binaries directly on the host. If you do want Pattern A on arm64, build the image on a native arm64 runner (self-hosted or GitHub-hosted arm) rather than via QEMU.
Pattern B walkthrough (native API under systemd)
This is the reference setup for a Raspberry Pi.
1. Infrastructure (containers)
cd <repo>
docker compose --env-file .env.pi --env-file .env.pi.db-admin up -d db seaweedfs
Already running MinIO? Keep it: start
minioinstead ofseaweedfs(... --profile minio up -d db minio). Your artifacts are in thetckdb_miniovolume, andseaweedfswould start on an empty one. To move them across, follow Moving from MinIO to SeaweedFS.
.env.pi holds only runtime DB credentials, S3 keys, rate-limit config,
cookie/security settings, etc. .env.pi.db-admin holds the database
administrator and migration-owner credentials. Keep both out of git, make
them mode 600, and never load .env.pi.db-admin into the API service. See
database_roles.md.
2. Environment (native)
Create the conda/micromamba env in a normal env location — not inside the repo:
# Good: central env location.
micromamba create -n tckdb_env -f backend/environment.yml
micromamba run -n tckdb_env pip install -e backend
micromamba run -n tckdb_env pip install -e schemas/python/tckdb-schemas
Do not use
micromamba create -p ./backend/y/envs/tckdb_env— the-p(path prefix) flag puts the whole multi-gigabyte environment inside the source tree. Use-n <name>so it lands in~/micromamba/envs/. (A conda env cannot be relocated withmv— its files bake in absolute paths — so fixing a misplaced env means recreating it at the right location and updating the systemdExecStartpath.)
3. Migrations
cd backend
set -a; source ../.env.pi; set +a
set -a; source ../.env.pi.db-admin; set +a
micromamba run -n tckdb_env python scripts/configure_database_roles.py apply
micromamba run -n tckdb_env alembic upgrade head
micromamba run -n tckdb_env python scripts/configure_database_roles.py check
(See the migrations runbook for the deployed-DB flow: back up first, check
alembic current, apply, verify.)
4. Run the API under systemd
/etc/systemd/system/tckdb-api.service:
[Unit]
Description=TCKDB API (uvicorn)
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=<you>
WorkingDirectory=<repo>/backend
EnvironmentFile=<repo>/.env.pi
ExecStart=<env-path>/bin/uvicorn app.api.app:create_app --factory --host 127.0.0.1 --port 8010
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now tckdb-api
systemctl status tckdb-api
journalctl -u tckdb-api -f # logs
Bind the API to 127.0.0.1 (loopback), not 0.0.0.0 — the ingress layer
reaches it locally; nothing else should.
Cross-cutting good practices (both patterns)
Exposure — tunnel, never a raw port
Put the public hostname behind a Cloudflare tunnel (or a reverse proxy you
control). Cloudflare's cloudflared connects outbound from your host to
Cloudflare and maps tckdb.example.com → localhost:8010. Benefits: no
inbound ports opened on your router, TLS terminated by Cloudflare, your home
IP hidden, and a DDoS/WAF layer for free. Never port-forward the API port raw
with a self-signed cert.
Tunnel ingress can be configured in the Cloudflare dashboard (remote-managed)
or in ~/.cloudflared/config.yml (local). Point one hostname at
http://localhost:8010.
Secrets
- The
.envfile is the only place credentials live; keep it out of git andchmod 600. - Rotate any secret that has ever appeared in a chat, ticket, or shell history.
Backups (do this)
Schedule a nightly pg_dump. A systemd timer is clean:
/etc/systemd/system/tckdb-backup.service:
[Unit]
Description=TCKDB Postgres backup (pg_dump)
After=docker.service
[Service]
Type=oneshot
User=<you>
ExecStart=<repo-or-home>/tckdb_backup.sh
/etc/systemd/system/tckdb-backup.timer:
[Unit]
Description=Run TCKDB backup daily
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
tckdb_backup.sh:
#!/usr/bin/env bash
set -euo pipefail
DEST=<backup-dir>; mkdir -p "$DEST"
FILE="$DEST/tckdb_$(date +%Y%m%d_%H%M%S).sql.gz"
docker exec <db-container> pg_dump -U <user> -d <db> | gzip > "$FILE"
find "$DEST" -name 'tckdb_*.sql.gz' -mtime +14 -delete
sudo systemctl enable --now tckdb-backup.timer
systemctl list-timers tckdb-backup.timer
Offsite copy. Backups that live only on the same disk as the database die with it. Add an
rsync/rclonestep to copy each dump to another machine or a cloud bucket.
Artifacts (the object store) are a separate backup concern; mirror the bucket
too if you rely on it (any S3 client works, e.g. rclone sync).
Migrations on a deployed DB
Follow backend/docs/deployment/migrations.md:
back up → alembic current → read revision docstrings → apply step-by-step →
verify. Never drop-and-recreate a deployed DB.
CI/CD (Pattern A)
build-api-image.yml builds and
publishes a multi-arch image (linux/amd64 + linux/arm64) to Docker Hub as
laxzal/tckdb-api on every push to main that touches the backend, and on a
v* git tag. The image is public: no docker login is needed to pull it.
Three tags are published, and which you use matters:
| Tag | Mutable? | Use for |
|---|---|---|
latest |
yes, it moves | a scratch pull; never pin a deployment to it |
sha-<full-commit> |
no | what a deployment should pin to — makes "which code is running" answerable |
v1.2.3 / v1.2 |
no | what a paper or a citation quotes |
Before publishing, CI runs the image: it must import (rdkit, tckdb_schemas,
the app factory), migrate a fresh RDKit-cartridge database to head, serve
/api/v1/health, and report status: ok with its inline worker thread alive.
Each architecture is built and checked natively (amd64 and arm64 runners, no
emulation), and the arm64 leg runs the import check, because arm64 is the
deployment target and a dependency with no arm64 build would otherwise only
fail on the Pi. That is the build half. The deploy half is deliberately
left to the operator, because it involves a choice and a migration step:
- Pull + restart on the host — options: a small webhook that runs
docker compose pull tckdb-api && docker compose up -d tckdb-api; a tool like Watchtower; or a scheduleddocker compose pull. - Run migrations as part of the deploy — e.g. a one-off
docker compose run --rm tckdb-api alembic upgrade headbefore the new API starts serving. Do not let a new image serve against an un-migrated DB.
Pin image tags by digest in production so a deploy is an explicit, reviewable
change rather than a moving :latest.
Quick reference
| Task | Command |
|---|---|
| Bring up infra | docker compose --env-file .env.pi --env-file .env.pi.db-admin up -d db seaweedfs (--profile minio ... db minio on a MinIO deployment) |
| API status / logs | systemctl status tckdb-api · journalctl -u tckdb-api -f |
| Restart API | sudo systemctl restart tckdb-api |
| Apply migrations | alembic upgrade head (env sourced; back up first) |
| Manual backup | sudo systemctl start tckdb-backup.service |
| Backup schedule | systemctl list-timers tckdb-backup.timer |