Deployment
Where it runs, how a deploy happens, and what to do when one fails.
Topology
languages.service.custom.mt on the shared services host, behind the shared Caddy edge proxy.
Internet ──▶ Caddy (:443, /opt/caddy)
└─ languages.service.custom.mt ──▶ language-service:8000
└─▶ postgres (own container, own volume)
The service has its own Postgres, in its own compose stack with its own volume. It has no connection to the legacy console database at any point — that database is read exactly once, by hand, to build the seed. See Rebuilding the Seed.
Everything lives in /opt/language-service/ on the host: docker-compose.prod.yml, .env, and the Postgres volume.
Auto-deploy
Push to main → CI → build → deploy, via .github/workflows/.
- CI — ruff, mypy, pytest against SQLite and Postgres, seed integrity check, Docker image build and smoke test.
- Build —
linux/arm64image pushed toghcr.io/custommt/language_service, tagged with the commit SHA andlatest. The host is Graviton; an amd64-only image would fail with "exec format error". - Deploy — over SSH: sync compose and Caddy files, pull,
alembic upgrade head, seed, restart, wait for the health gate, reload Caddy.
Gated on the repository variable DEPLOY_ENABLED=true. Secrets: DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY.
The seed runs on every deploy
python -m app.cli seed is idempotent and upsert-shaped: it matches rows on their natural keys, updates in place, and never deletes. Edits made in the admin panel to rows the seed does not know about are left alone. A pinned code the seed does know about will be reset to the seeded value — so a correction meant to be permanent belongs in the seed, not only in the panel.
Configuration
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL |
yes | postgresql+asyncpg://… |
APP_ENV |
yes | production on the server |
ADMIN_USERNAME |
yes | |
ADMIN_PASSWORD_HASH_FILE |
yes | file holding the argon2id hash — see Admin Panel |
ADMIN_SECRET_KEY_FILE |
yes | openssl rand -hex 48 into a file |
ADMIN_ENABLED |
no | false removes the panel entirely |
DOCS_ENABLED |
no | Swagger UI. /redoc and /wiki are always public |
CACHE_TTL_SECONDS |
no | default 300 |
With APP_ENV=production and the admin enabled, a missing ADMIN_SECRET_KEY or ADMIN_PASSWORD_HASH is a startup error. Secrets are generated on the server and never leave it.
Health
| Probe | Checks | Use |
|---|---|---|
/health |
nothing — the process is running | container liveness |
/health/ready |
database reachable, catalog non-empty | deploy gate, load balancer |
Readiness fails when migrations ran but the seed did not: an empty catalog would answer 404 to everything.
Operating
cd /opt/language-service
docker compose -f docker-compose.prod.yml logs -f api
docker compose -f docker-compose.prod.yml run --rm api python -m app.cli stats
docker compose -f docker-compose.prod.yml run --rm api python -m app.cli resolve deepl_api pt_BR
# rotate the admin password
docker compose -f docker-compose.prod.yml run --rm api python -m app.cli hash-password
# → paste into .env, then:
docker compose -f docker-compose.prod.yml up -d api
Logs are one JSON object per line: event, level, plus per-event fields. Request lines carry correlation_id.
Rolling back
Deploy a previous image tag:
cd /opt/language-service
sed -i "s|^IMAGE=.*|IMAGE=ghcr.io/custommt/language_service:<sha>|" .env
docker compose -f docker-compose.prod.yml up -d api
Migrations are additive, so one release back needs no downgrade.
When a deploy fails
| Symptom | Check |
|---|---|
| Container unhealthy | logs api — usually DATABASE_URL or a missing admin secret |
/health/ready 503, catalog: false |
the seed did not run: run --rm api python -m app.cli seed |
| 502 from Caddy | the container is not on the edge network, or the site file did not sync |
| TLS not issued | DNS: *.service.custom.mt must resolve to the host |
Next
- Rebuilding the Seed — refreshing the catalog from the console
- Admin Panel