lila/documentation/deployment.md
lila e5595b5039
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
updating documentation
2026-04-14 19:35:49 +02:00

283 lines
9.5 KiB
Markdown

# Deployment Guide — lilastudy.com
This document describes the production deployment of the lila vocabulary trainer on a Hetzner VPS.
## Infrastructure Overview
- **VPS**: Hetzner, Debian 13, ARM64 (aarch64), 4GB RAM
- **Domain**: lilastudy.com (DNS managed on Hetzner, wildcard `*.lilastudy.com` configured)
- **Reverse proxy**: Caddy (Docker container, automatic HTTPS via Let's Encrypt)
- **Container registry**: Forgejo built-in package registry
- **Git server**: Forgejo
### Subdomain Routing
| Subdomain | Service | Container port |
|---|---|---|
| `lilastudy.com` | Frontend (nginx serving static files) | 80 |
| `api.lilastudy.com` | Express API | 3000 |
| `git.lilastudy.com` | Forgejo (web UI + container registry) | 3000 |
### Ports Exposed to the Internet
| Port | Service |
|---|---|
| 80 | Caddy (HTTP, redirects to HTTPS) |
| 443 | Caddy (HTTPS) |
| 2222 | Forgejo SSH (git clone/push) |
All other services (Postgres, API, frontend) communicate only over the internal Docker network.
## VPS Base Setup
The server has SSH key auth, ufw firewall (ports 22, 80, 443, 2222), and fail2ban configured. Docker and Docker Compose are installed via Docker's official apt repository.
Locale `en_GB.UTF-8` was generated alongside `en_US.UTF-8` to suppress SSH locale warnings from the dev laptop.
## Directory Structure on VPS
```
~/lila-app/
├── docker-compose.yml
├── Caddyfile
└── .env
~/lila-db-backups/
├── lila-db-YYYY-MM-DD_HHMM.sql.gz
└── backup.sh
```
## Docker Compose Stack
All services run in a single `docker-compose.yml` on a shared `lila-network`. The app images are pulled from the Forgejo registry.
### Services
- **caddy** — reverse proxy, only container with published ports (80, 443)
- **api** — Express backend, image from `git.lilastudy.com/forgejo-lila/lila-api:latest`
- **web** — nginx serving Vite-built static files, image from `git.lilastudy.com/forgejo-lila/lila-web:latest`
- **database** — PostgreSQL with a named volume (`lila-db`) for persistence
- **forgejo** — git server + container registry, SSH on port 2222, data in named volume (`forgejo-data`)
### Key Design Decisions
- No ports exposed on internal services — only Caddy faces the internet
- Frontend is built to static files at Docker build time; no Node process in production
- `VITE_API_URL` is baked in during the Docker build via a build arg
- The API reads all environment-specific config from `.env` (CORS origin, auth URLs, DB connection, cookie domain)
## Environment Variables
Production `.env` on the VPS:
```
DATABASE_URL=postgres://postgres:PASSWORD@database:5432/lila
POSTGRES_USER=postgres
POSTGRES_PASSWORD=PASSWORD
POSTGRES_DB=lila
BETTER_AUTH_SECRET=GENERATED_SECRET
BETTER_AUTH_URL=https://api.lilastudy.com
CORS_ORIGIN=https://lilastudy.com
COOKIE_DOMAIN=.lilastudy.com
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
```
Note: `DATABASE_URL` host is `database` (the Docker service name). Password in `DATABASE_URL` must match `POSTGRES_PASSWORD`.
## Docker Images — Build and Deploy
Images are built on the dev laptop with cross-compilation for ARM64, pushed to the Forgejo registry, and pulled on the VPS.
### Build (on dev laptop)
```bash
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker build --platform linux/arm64 \
-t git.lilastudy.com/forgejo-lila/lila-api:latest \
--target runner -f apps/api/Dockerfile .
docker build --platform linux/arm64 \
-t git.lilastudy.com/forgejo-lila/lila-web:latest \
--target production \
--build-arg VITE_API_URL=https://api.lilastudy.com \
-f apps/web/Dockerfile .
```
QEMU registration may need to be re-run after Docker or system restarts.
### Push (from dev laptop)
```bash
docker login git.lilastudy.com
docker push git.lilastudy.com/forgejo-lila/lila-api:latest
docker push git.lilastudy.com/forgejo-lila/lila-web:latest
```
### Deploy (on VPS)
```bash
docker compose pull
docker compose up -d
```
To deploy a single service without restarting the whole stack:
```bash
docker compose pull api
docker compose up -d api
```
### Cleanup
Remove unused images after deployments:
```bash
docker image prune -f # safe — only removes dangling images
docker system prune -a # aggressive — removes all unused images
```
## Dockerfiles
### API (`apps/api/Dockerfile`)
Multi-stage build: base → deps → dev → builder → runner. The `runner` stage does a fresh `pnpm install --prod` to get correct symlinks. Output is at `apps/api/dist/src/server.js` due to monorepo rootDir configuration.
### Frontend (`apps/web/Dockerfile`)
Multi-stage build: base → deps → dev → builder → production. The `builder` stage compiles with `VITE_API_URL` baked in. The `production` stage is `nginx:alpine` serving static files from `dist/`. Includes a custom `nginx.conf` for SPA fallback routing (`try_files $uri $uri/ /index.html`).
## Monorepo Package Exports
Both `packages/shared` and `packages/db` have their `exports` in `package.json` pointing to compiled JavaScript (`./dist/src/...`), not TypeScript source. This is required for production builds where Node cannot run `.ts` files. In dev, packages must be built before running the API.
## Database
### Initial Seeding
The production database was initially populated via `pg_dump` from the dev laptop:
```bash
# On dev laptop
docker exec lila-database pg_dump -U USER DB > seed.sql
scp seed.sql lila@VPS_IP:~/lila-app/
# On VPS
docker exec -i lila-database psql -U postgres -d lila < seed.sql
```
### Ongoing Data Updates
The seeding script (`packages/db/src/seeding-datafiles.ts`) uses `onConflictDoNothing()` on all inserts, making it idempotent. New vocabulary data (e.g. Spanish words) can be added by running the seeding script against production — it inserts only new records without affecting existing data or user tables.
### Schema Migrations
Schema changes are managed by Drizzle. Deploy order matters:
1. Run migration first (database gets new structure)
2. Deploy new API image (code uses new structure)
Reversing this order causes the API to crash on missing columns/tables.
## Backups
A cron job runs daily at 3:00 AM, dumping the database to a compressed SQL file and keeping the last 7 days:
```bash
# ~/backup.sh
0 3 * * * /home/lila/backup.sh
```
Backups are stored in `~/backups/` as `lila-db-YYYY-MM-DD_HHMM.sql.gz`.
### Pulling Backups to Dev Laptop
A script on the dev laptop syncs new backups on login:
```bash
# ~/pull-backups.sh (runs via .profile on login)
rsync -avz --ignore-existing --include="*.sql.gz" --exclude="*" lila@VPS_IP:~/backups/ ~/lila-backups/
```
### Restoring from Backup
```bash
gunzip -c lila-db-YYYY-MM-DD_HHMM.sql.gz | docker exec -i lila-database psql -U postgres -d lila
```
## OAuth Configuration
Google and GitHub OAuth apps must have both dev and production redirect URIs:
- **Google Cloud Console**: Authorized redirect URIs include both `http://localhost:3000/api/auth/callback/google` and `https://api.lilastudy.com/api/auth/callback/google`
- **GitHub Developer Settings**: Authorization callback URL includes both localhost and production
## Forgejo SSH
The dev laptop's `~/.ssh/config` maps `git.lilastudy.com` to port 2222:
```
Host git.lilastudy.com
Port 2222
```
This allows standard git commands without specifying the port.
## CI/CD Pipeline
Automated build and deploy via Forgejo Actions. On every push to `main`, the pipeline builds ARM64 images natively on the VPS, pushes them to the Forgejo registry, and restarts the app containers.
### Components
- **Forgejo Actions** — enabled by default, workflow files in `.forgejo/workflows/`
- **Forgejo Runner** — runs as a container (`lila-ci-runner`) on the VPS, uses the host's Docker socket to build images natively on ARM64
- **Workflow file** — `.forgejo/workflows/deploy.yml`
### Pipeline Steps
1. Install Docker CLI and SSH client in the job container
2. Checkout the repository
3. Login to the Forgejo container registry
4. Build API image (target: `runner`)
5. Build Web image (target: `production`, with `VITE_API_URL` baked in)
6. Push both images to `git.lilastudy.com`
7. SSH into the VPS, pull new images, restart `api` and `web` containers, prune old images
### Secrets (stored in Forgejo repo settings → Actions → Secrets)
| Secret | Value |
|---|---|
| REGISTRY_USER | Forgejo username |
| REGISTRY_PASSWORD | Forgejo password |
| SSH_PRIVATE_KEY | Contents of `~/.ssh/ci-runner` on the VPS |
| SSH_HOST | VPS IP address |
| SSH_USER | `lila` |
### Runner Configuration
The runner config is at `/data/config.yml` inside the `lila-ci-runner` container. Key settings:
- `docker_host: "automount"` — mounts the host Docker socket into job containers
- `valid_volumes: ["/var/run/docker.sock"]` — allows the socket mount
- `privileged: true` — required for Docker access from job containers
- `options: "--group-add 989"` — adds the host's docker group (GID 989) to job containers
The runner command must explicitly reference the config file:
```yaml
command: '/bin/sh -c "sleep 5; forgejo-runner -c /data/config.yml daemon"'
```
### Deploy Cycle
Push to main → pipeline runs automatically (~2-5 min) → app is updated. No manual steps required.
To manually trigger a re-run: go to the repo's Actions tab, click on the latest run, and use the re-run button.
## Known Issues and Future Work
- **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added
- **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state
- **Monitoring/logging**: No centralized logging or uptime monitoring configured