| name | Production Deployment |
| description | Deploy sertantai-legal to production on Hetzner. Build Docker images, push to GHCR, deploy via SSH, and manage ElectricSQL/Nginx/PostgreSQL infrastructure. |
SKILL: Production Deployment to Hetzner
Purpose: Deploy sertantai-legal (or a new microservice) to the shared Hetzner infrastructure
Context: Docker, GHCR, PostgreSQL 16, ElectricSQL, Nginx, Let's Encrypt, ~/Desktop/infrastructure
When to Use:
- First-time deployment of a new microservice
- Rebuilding and redeploying after code changes
- Restoring production data from dev
Core Principles
- Infrastructure is shared — PostgreSQL, Redis, Nginx, and networking live in
~/Desktop/infrastructure, not in the service repo
- Schema before data — Never restore a database dump until migrations have been verified to produce an identical schema
- Migrations are the source of truth — If a column exists in dev but not in a migration, it won't exist in prod. Fix the migration first.
- ElectricSQL instances need unique identifiers — Multiple Electric instances on the same PostgreSQL cluster will conflict on replication slots
- Alpine versions must match — The Docker builder stage and runner stage must use the same Alpine/OpenSSL version or NIF libraries will fail to load
- No host port mappings — All services communicate via the
infra_network Docker network. Health checks must use docker inspect or docker exec, not curl localhost.
Deployment Scripts
All deployment scripts live in scripts/deployment/ (run from the repo root):
| Script | Purpose |
|---|
./scripts/deployment/build-frontend.sh | Build frontend Docker image |
./scripts/deployment/build-backend.sh | Build backend Docker image |
./scripts/deployment/push-frontend.sh | Push frontend image to GHCR |
./scripts/deployment/push-backend.sh | Push backend image to GHCR |
./scripts/deployment/deploy-prod.sh | Deploy to production server |
Shell aliases (sert-legal-fe, sert-legal-be, etc.) exist in ~/.bashrc but are not available inside Claude Code sessions. Always use the full script paths.
deploy-prod.sh Options
./scripts/deployment/deploy-prod.sh
./scripts/deployment/deploy-prod.sh --frontend
./scripts/deployment/deploy-prod.sh --backend
./scripts/deployment/deploy-prod.sh --electric
./scripts/deployment/deploy-prod.sh --with-electric
./scripts/deployment/deploy-prod.sh --electric-clear-cache
./scripts/deployment/deploy-prod.sh --migrate
./scripts/deployment/deploy-prod.sh --check-only
./scripts/deployment/deploy-prod.sh --logs
Typical Deploy Workflow
./scripts/deployment/build-frontend.sh && ./scripts/deployment/push-frontend.sh
./scripts/deployment/build-backend.sh && ./scripts/deployment/push-backend.sh
./scripts/deployment/deploy-prod.sh
./scripts/deployment/build-frontend.sh && ./scripts/deployment/push-frontend.sh && ./scripts/deployment/deploy-prod.sh --frontend
Infrastructure Files to Modify
When adding a new service, these files in ~/Desktop/infrastructure need updating:
| File | Change |
|---|
docker/docker-compose.yml | Add service + Electric containers |
docker/.env.example | Add service-specific env vars |
data/postgres-init/01-create-databases.sql | Add CREATE DATABASE + extensions |
nginx/conf.d/<domain>.conf | Create nginx config with SSL, API proxy, Electric proxy |
Commit and push these changes so they can be git pulled on the server.
Container Architecture
All services run on the internal infra_network Docker network with no host port mappings. Nginx is the sole entry point.
legal.sertantai.com (Nginx :443)
/ → sertantai-legal-frontend:3000 (SvelteKit via serve)
/api/ → sertantai-legal:4000 (Phoenix API)
/electric/ → sertantai-legal-electric:3000 (ElectricSQL)
/health → sertantai-legal:4000/health
Container Health Checks
Health checks are defined in docker-compose.yml and use tools available inside each container:
| Container | Tool | Command |
|---|
sertantai_legal_app | wget | wget --spider http://localhost:4000/health |
sertantai_legal_frontend | wget | wget --spider http://localhost:3000/ |
sertantai_legal_electric | curl | curl -f http://localhost:3000/v1/health |
Important: The backend container does NOT have curl — use wget for health checks. The deploy script checks health via docker inspect --format='{{.State.Health.Status}}'.
Common Pitfalls & Solutions
Pitfall 1: Elixir Regex in Module Attributes (Elixir 1.18+)
Elixir 1.18+ forbids NIF references (compiled Regex structs) in module attributes injected into function bodies. Local compilation may succeed due to cached BEAM files, but Docker builds compile fresh and will fail.
# error: Failed to load NIF library
- Store raw pattern strings in module attributes
- Compile to Regex at runtime using
:persistent_term for caching
- See
backend/lib/sertantai_legal/legal/taxa/actor_definitions.ex for the pattern
Pitfall 2: Alpine Version Mismatch in Dockerfile
# error: Error relocating crypto.so: EVP_PKEY_sign_message_init: symbol not found
The elixir:1.18.4-alpine image uses Alpine 3.23 with OpenSSL 3.5. If your runner stage uses an older Alpine, the Erlang crypto NIF won't load.
# Check what Alpine the builder uses:
# docker run --rm elixir:1.18.4-alpine cat /etc/alpine-release
# → 3.23.3
# Runner MUST match:
FROM alpine:3.23 # NOT 3.19 or 3.21
Pitfall 3: ElectricSQL Replication Slot Conflict
Multiple Electric instances default to electric_slot_default. Replication slots are cluster-wide in PostgreSQL — even across different databases, the lock acquisition will conflict.
environment:
- ELECTRIC_REPLICATION_STREAM_ID=legal
ELECTRIC_SLOT_NAME and ELECTRIC_PUBLICATION_NAME env vars do NOT work in ElectricSQL 1.4+. Use ELECTRIC_REPLICATION_STREAM_ID instead.
Pitfall 4: GHCR Authentication
- GHCR packages are private by default — the server needs
docker login ghcr.io with a PAT that has read:packages scope
- PATs expire — if pushes or pulls suddenly fail with
denied, regenerate the PAT
- The
gh CLI token is separate from the Docker credential store token
- Push scripts check
~/.docker/config.json for GHCR credentials and fail fast if not logged in
echo "YOUR_PAT" | docker login ghcr.io -u shotleybuilder --password-stdin
Pitfall 5: pg_dump/pg_restore vs psql COPY
Never use psql -f or pipe SQL text files for data restores. PostgreSQL 16's pg_dump adds \restrict directives, and COPY FROM STDIN blocks require precise stdin handling that breaks through Docker exec.
pg_dump --format=custom -f dump.dump
pg_restore dump.dump
Pitfall 6: Schema Drift Between Dev and Prod
If dev was populated from a legacy dump (e.g., Airtable export), it may have columns that don't exist in Ash migrations. The pg_restore will fail with "column X does not exist".
Fix: Compare schemas, then create a migration to align them BEFORE restoring data.
psql -d dev_db -c "SELECT count(*) FROM information_schema.columns WHERE table_name = 'uk_lrt';"
psql -d prod_db -c "SELECT count(*) FROM information_schema.columns WHERE table_name = 'uk_lrt';"
Pitfall 7: SSL Cert with Nginx Already Running
certbot --webroot fails if nginx catches the ACME challenge request and routes it to another service. Use standalone mode instead:
docker compose stop nginx
sudo certbot certonly --standalone -d legal.sertantai.com
docker compose start nginx
Pitfall 8: postgres-init SQL Only Runs Once
The data/postgres-init/01-create-databases.sql only executes on first PostgreSQL container creation. If postgres is already running, create the database manually:
docker exec shared_postgres psql -U postgres -c "CREATE DATABASE sertantai_legal_prod;"
docker exec shared_postgres psql -U postgres -d sertantai_legal_prod \
-c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"; CREATE EXTENSION IF NOT EXISTS \"citext\";"
Pitfall 9: Health Check Tool Mismatch
Backend container is Alpine-based and only has wget, not curl. If docker-compose.yml healthcheck uses curl, the container will always show unhealthy.
test: ["CMD", "curl", "-f", "http://localhost:4000/health"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4000/health"]
Working Deployment Sequence
Phase 1: Prepare Infrastructure (on laptop)
cd ~/Desktop/infrastructure
git add -A && git commit -m "feat: Add <service> infrastructure"
git push
Phase 2: Build and Push Docker Images (on laptop)
./scripts/deployment/build-backend.sh
./scripts/deployment/build-frontend.sh
./scripts/deployment/push-backend.sh
./scripts/deployment/push-frontend.sh
Phase 3: Server Setup (SSH to hetzner — first time only)
ssh sertantai-hz
cd ~/infrastructure
git pull
dig legal.sertantai.com +short
openssl rand -base64 64 | tr -d '\n' && echo
openssl rand -base64 32 | tr -d '\n' && echo
nano docker/.env
docker exec shared_postgres psql -U postgres -c "CREATE DATABASE sertantai_legal_prod;"
docker exec shared_postgres psql -U postgres -d sertantai_legal_prod \
-c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"; CREATE EXTENSION IF NOT EXISTS \"citext\";"
docker compose -f docker/docker-compose.yml stop nginx
sudo certbot certonly --standalone -d legal.sertantai.com
docker compose -f docker/docker-compose.yml start nginx
Phase 4: Deploy Services
./scripts/deployment/deploy-prod.sh
./scripts/deployment/deploy-prod.sh --frontend
./scripts/deployment/deploy-prod.sh --backend
./scripts/deployment/deploy-prod.sh --backend --with-electric
Phase 5: Populate Data (on laptop, then server)
Critical: Verify schema parity first
PGPASSWORD=postgres psql -h localhost -p 5436 -U postgres -d sertantai_legal_dev \
-c "SELECT count(*) FROM information_schema.columns WHERE table_name = 'uk_lrt';"
docker exec shared_postgres psql -U postgres -d sertantai_legal_prod \
-c "SELECT count(*) FROM information_schema.columns WHERE table_name = 'uk_lrt';"
If they match, dump and restore:
PGPASSWORD=postgres pg_dump -h localhost -p 5436 -U postgres \
-d sertantai_legal_dev --data-only --no-owner --no-acl \
--format=custom -f /tmp/sertantai_legal_data.dump
scp /tmp/sertantai_legal_data.dump sertantai-hz:/tmp/
docker compose stop sertantai-legal sertantai-legal-electric
docker cp /tmp/sertantai_legal_data.dump shared_postgres:/tmp/
docker exec shared_postgres pg_restore -U postgres -d sertantai_legal_prod \
--data-only --no-owner --no-acl --disable-triggers \
/tmp/sertantai_legal_data.dump
docker exec shared_postgres psql -U postgres -d sertantai_legal_prod \
-c "SELECT count(*) FROM uk_lrt;"
docker compose up -d --force-recreate sertantai-legal-electric
docker compose up -d --force-recreate sertantai-legal
docker compose exec nginx nginx -s reload
If schemas don't match:
- Find the missing columns: compare column lists from both databases
- Determine if they should be in a migration (used by the app) or dropped (legacy cruft)
- Create and run the migration on dev first, then rebuild the Docker image
- Deploy the new image to prod, verify column counts match, then restore data
Troubleshooting
Backend container keeps restarting
docker compose logs sertantai-legal --tail 50
- crypto NIF error → Alpine version mismatch (see Pitfall 2)
- database does not exist → Create it manually (see Pitfall 8)
- connect raised UndefinedFunctionError → Usually the crypto NIF issue
Backend shows "unhealthy" but logs show 200s
The docker-compose healthcheck is using a tool not available in the container (e.g., curl instead of wget). See Pitfall 9.
ElectricSQL stuck on "waiting_on_lock"
docker compose logs sertantai-legal-electric --tail 50
502 Bad Gateway after deploy
Backend takes ~5-10 seconds to start (runs migrations first). The deploy script retries health checks 6 times over 30 seconds. If still failing, check logs.
pg_restore fails with "column X does not exist"
Schema drift — see Pitfall 6. Fix migrations before restoring data.
pg_restore fails with "duplicate key" on schema_migrations
Harmless — the migrations table was already populated when the container ran migrations on startup. Use --data-only to avoid this, or ignore the warning.
Quick Reference
SSH to server
ssh sertantai-hz
Key paths on server
| Path | Purpose |
|---|
~/infrastructure/docker/ | docker-compose.yml and .env |
~/infrastructure/nginx/conf.d/ | Nginx site configs |
/etc/letsencrypt/live/legal.sertantai.com/ | SSL certs |
Key ports (internal to Docker network — no host mappings)
| Service | Container Name | Port |
|---|
| PostgreSQL | shared_postgres | 5432 |
| sertantai-legal (Phoenix) | sertantai_legal_app | 4000 |
| sertantai-legal-electric | sertantai_legal_electric | 3000 |
| sertantai-legal-frontend | sertantai_legal_frontend | 3000 |
| sertantai-auth | sertantai_auth_app | 4001 |
| sertantai-enforcement | sertantai_enforcement_app | 4002 |
Docker image names
ghcr.io/shotleybuilder/sertantai-legal-backend:latest
ghcr.io/shotleybuilder/sertantai-legal-frontend:latest
Rebuild and deploy cycle
./scripts/deployment/build-backend.sh && ./scripts/deployment/push-backend.sh && \
./scripts/deployment/build-frontend.sh && ./scripts/deployment/push-frontend.sh && \
./scripts/deployment/deploy-prod.sh
./scripts/deployment/build-frontend.sh && ./scripts/deployment/push-frontend.sh && \
./scripts/deployment/deploy-prod.sh --frontend
./scripts/deployment/build-backend.sh && ./scripts/deployment/push-backend.sh && \
./scripts/deployment/deploy-prod.sh --backend
Related Skills
Key Takeaways
- Use the deployment scripts —
./scripts/deployment/deploy-prod.sh handles SSH, pulling, restarting, and health checks
- Always use
--format=custom for pg_dump/pg_restore — never plain SQL text
- Always verify schema parity before restoring data
- Always use
ELECTRIC_REPLICATION_STREAM_ID when running multiple Electric instances
- Always match Alpine versions between Docker builder and runner stages
- Always use
wget not curl for backend container health checks
- Always check GHCR PAT expiry when pushes/pulls fail with "denied"
- Never use
docker compose down -v on production — it destroys data volumes
- Never assume localhost is reachable — services have no host port mappings, use
docker inspect or docker exec