docs/DEPLOYMENT.md
Rama main · réplica de lectura del repositorio original.
# Deployment
Everything runs in Docker. There are two compose files:
| File | Purpose |
|------|---------|
| `docker-compose.yml` | **Dev** — Postgres + Django dev server + Vite (hot reload) |
| `docker-compose.prod.yml` | **Prod** — Postgres + gunicorn + nginx (single origin) |
Both read configuration from a root `.env` (copy it from `.env.example`).
## Development
```bash
cp .env.example .env
docker compose up --build
```
Services:
| Service | Detail | Host port |
|---------|--------|-----------|
| `db` | postgres:16-alpine, named volume `pgdata`, healthcheck | **5433** → 5432 |
| `backend` | `manage.py runserver`, source bind-mounted (autoreload), `RUN_COLLECTSTATIC=0` | 8000 |
| `frontend` | Vite dev server with HMR, proxies `/api` → `http://backend:8000` | 5173 |
- Open the app at **http://localhost:5173**, the API at **http://localhost:8000/api/**.
- The DB host port is **5433** (set `POSTGRES_HOST_PORT` to change it) so it won't clash
with a local Postgres on 5432. Inside Docker the backend still connects to `db:5432`.
- Migrations run automatically on backend start (the entrypoint waits for Postgres, then
`migrate`).
### First-run data
```bash
docker compose exec backend python manage.py load_sample # demo project + demo/editor/viewer
docker compose exec backend python manage.py createsuperuser # admin login for /admin
```
> The dev database holds real accounts and projects — don't `flush` it. To reset, drop the
> volume: `docker compose down -v` (this deletes all data), then `up` and re-seed.
## Environment variables
Defined in `.env` and injected into the containers.
| Variable | Used by | Notes |
|----------|---------|-------|
| `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` | db, backend | database name / role / password |
| `POSTGRES_HOST` / `POSTGRES_PORT` | backend | `db` / `5432` (compose service) |
| `POSTGRES_HOST_PORT` | db (dev) | host-side port for the dev DB (default `5433`) |
| `DATABASE_URL` | backend | e.g. `postgres://timeline:timeline@db:5432/timeline` |
| `DJANGO_SECRET_KEY` | backend | **set a strong value in prod** |
| `DJANGO_DEBUG` | backend | `1` dev / `0` prod |
| `DJANGO_ALLOWED_HOSTS` | backend | comma-separated hostnames |
| `DJANGO_CORS_ALLOWED_ORIGINS` | backend | dev only (Vite origin); empty in same-origin prod |
| `DJANGO_CSRF_TRUSTED_ORIGINS` | backend | prod: your `https://` origin(s) — needed for `/admin` login |
| `DJANGO_SECURE_SSL_REDIRECT` | backend | prod, behind TLS |
| `RUN_COLLECTSTATIC` | backend | `0` dev, `1` prod (entrypoint runs `collectstatic`) |
| `REQUIRE_ACCOUNT_APPROVAL` | backend | `1` (default): new sign-ups inactive until approved; `0` to disable |
| `APP_NAME` | backend | The product's name in emails, exported status reports and calendar files (default `Seedcorn`). Set it to put your own name on your copy |
| `VITE_APP_NAME` | frontend (build time) | The same name inside the app and in the browser tab. A build argument, like `VITE_DEMO_BANNER`; the default is in `frontend/brand.json` |
| `DEFAULT_CURRENCY` | backend | Three-letter currency offered first when a project is closed out (default `USD`). Figures are never converted |
| `TEMPLATE_LIBRARY` | backend | How far a saved template may be shared: `instance` (default; teams or everyone signed in), `teams`, or `off` (templates stay private) |
| `EMAIL_HOST_USER` / `EMAIL_HOST_PASSWORD` | backend | Gmail account + App Password. Empty ⇒ console backend (dev) |
| `EMAIL_HOST` / `EMAIL_PORT` / `EMAIL_USE_TLS` | backend | SMTP server (default `smtp.gmail.com` / `587` / on) |
| `DEFAULT_FROM_EMAIL` | backend | `From:` on outgoing email (defaults to `EMAIL_HOST_USER`) |
| `ACCOUNT_NOTIFY_EMAIL` | backend | inbox that receives "new account pending approval" alerts |
| `SITE_URL` | backend | public app URL used in email links (sign-in / admin) |
| `VITE_PROXY_TARGET` | frontend (dev) | `http://backend:8000` |
Generate a production secret:
```bash
python -c "import secrets; print(secrets.token_urlsafe(64))"
```
## Production
Single-origin topology with **HTTPS**: the app sits behind Cloudflare's proxy, which terminates
the browser-facing TLS at its edge with a browser-trusted certificate. **nginx** presents a
**Cloudflare Origin Certificate** so the Cloudflare→origin leg is encrypted and validated
(Cloudflare SSL/TLS mode **Full (strict)**), serves the built React bundle, and reverse-proxies
`/api`, `/admin`, `/static`, and `/media` to **gunicorn**. Same origin → no CORS.
`docker-compose.prod.yml` runs db + backend (gunicorn) + nginx (80 + 443). It expects a domain
and an origin certificate. Create the cert in the Cloudflare dashboard (**SSL/TLS → Origin
Server → Create Certificate**) and save the Origin Certificate + Private Key to
`nginx/certs/origin.pem` and `nginx/certs/origin.key` (mounted read-only into the nginx
container; both are gitignored). Then:
```bash
cp .env.example .env
# Edit .env: DJANGO_DEBUG=0, a strong DJANGO_SECRET_KEY + POSTGRES_PASSWORD,
# DOMAIN=<your domain>, RUN_COLLECTSTATIC=1,
# DJANGO_ALLOWED_HOSTS=<your domain>, DJANGO_CSRF_TRUSTED_ORIGINS=https://<your domain>
# Place the Cloudflare Origin Certificate at nginx/certs/origin.pem + origin.key
docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml exec backend python manage.py createsuperuser
```
- `migrate` + `collectstatic` run automatically on backend start; the Postgres port is **not**
published. The Cloudflare Origin Certificate is valid for ~15 years, so there's nothing to
renew. nginx falls back to `index.html` for client-side routing.
- **`WEB_CONCURRENCY`** in `.env` sets gunicorn worker count (tune to the instance).
- **`/api/health/`** is an unauthenticated health probe for monitoring / load balancers.
Redeploy after changes:
```bash
git pull && docker compose -f docker-compose.prod.yml up -d --build
```
> **Full AWS walkthrough** (EC2 + elastic IP + Cloudflare + Terraform + instance sizing):
> see **[AWS Deployment](AWS_DEPLOYMENT.md)**.
### Static & media
- **Django static** (admin + DRF browsable API): `collectstatic` → `static_volume`, served
by nginx at `/static/` (whitenoise is a fallback under gunicorn).
- **Media** (uploads, if added later): `/media/` from `media_volume`.
### TLS
Cloudflare terminates the browser-facing TLS at its edge (Universal SSL). nginx serves a
**Cloudflare Origin Certificate** on `443` so the Cloudflare→origin leg is encrypted and
validated, with the zone's SSL/TLS mode set to **Full (strict)**. Django honors
`X-Forwarded-Proto` via `SECURE_PROXY_SSL_HEADER`, and prod enables HSTS + secure cookies when
`DJANGO_DEBUG=0`.
## Account approval & email
By default (`REQUIRE_ACCOUNT_APPROVAL=1`) nobody can use the app until **you** approve them:
1. A visitor registers → their account is created **inactive** and they see a "pending
approval" screen. They cannot obtain a token / sign in.
2. You get an email at **`ACCOUNT_NOTIFY_EMAIL`** with their details and a link to the admin.
3. In **Django admin → Users**, open the account and tick **Active** (the newest sign-ups sort
to the top), or select them on the list and run **"Approve & notify"**. Either way the
person is automatically emailed that their account is ready.
4. They sign in with **email or username** + password.
**Email** carries two things: account approval notices and **password reset links** (the
"Forgot your password?" link on the sign-in page). Without working email a reset link is only
written to the backend log, so nobody can recover an account by themselves.
**Email backend** is configured via the `EMAIL_HOST_*` vars. (Django 6.1 replaced its `EMAIL_*`
settings with `MAILERS`; `settings.py` builds that from these same variable names, so an existing
`.env` keeps working.) In dev, with no SMTP credentials
set, it falls back to the console backend (messages print to the backend container logs —
`docker compose logs backend`), so nothing is actually sent. For prod, set the Gmail SMTP
credentials — an **App Password** (not your account password; requires 2-Step Verification):
```bash
EMAIL_HOST_USER=you@gmail.com
EMAIL_HOST_PASSWORD=your-16-char-app-password
ACCOUNT_NOTIFY_EMAIL=you@gmail.com
SITE_URL=https://yourdomain
# EMAIL_HOST/PORT/USE_TLS default to smtp.gmail.com / 587 / on; DEFAULT_FROM_EMAIL
# defaults to EMAIL_HOST_USER. Setting EMAIL_HOST_USER switches on the SMTP backend.
```
> To run an open instance (no approval), set `REQUIRE_ACCOUNT_APPROVAL=0` — registrations become
> active immediately and sign in seamlessly.
## Continuous integration
`.github/workflows/ci.yml` runs on every push/PR:
- **backend** — spins up a Postgres service container, then `manage.py check`,
`makemigrations --check`, `migrate`, and `test` (ruff lint is advisory).
- **frontend** — `npm ci` + `npm run build`.
## Keeping it current
- **Requirements:** Python 3.12, Django 6.1, PostgreSQL **15 or newer** (Django 6.1 refuses older).
- **Dependencies:** Dependabot alerts, security updates and secret scanning are enabled on the
repository and `.github/dependabot.yml` opens grouped update pull requests. Before a release,
`pip-audit` in the backend image and `npm audit` in the frontend should both be clean.
- **The host:** patch the operating system regularly, not just the containers. A host that has
been up for months is running an old kernel. Upgrading usually restarts Docker (a short
outage) and needs a reboot; all three services use `restart: always`, so they return alone.
Take a disk snapshot first.
- **PostgreSQL minor releases** (16.x → 16.y) share a data format: `docker compose pull db`, then
`up -d --no-deps db`, with a fresh `pg_dump` beforehand. A **major** upgrade needs dump and restore.
- **Before swapping containers,** try the new images against the real `.env` while the old ones
still serve: `docker compose -f docker-compose.prod.yml run --rm --no-deps --entrypoint python
backend manage.py check --deploy` and `… migrate --plan`, and `… run --rm --no-deps nginx nginx -T`
(the command must start with `nginx`, or the image skips rendering your config template).
- **Disk:** every deploy leaves the previous images behind. `docker image prune` now and then,
keeping the last rollback tag.
## Operational notes
- **Apple silicon / arm64:** all base images (`python:3.12-slim`, `postgres:16-alpine`,
`node:24-alpine`, `nginx:1.30-alpine`) are multi-arch — no emulation needed. Building for
an amd64 server from a Mac: `--platform linux/amd64` (buildx).
- **Port collisions:** dev publishes `5433` (db), `8000` (api), `5173` (web). Override via
env or stop the conflicting service.
- **Secrets:** `.env` is gitignored — never commit it. Use strong `DJANGO_SECRET_KEY` and
`POSTGRES_PASSWORD` in prod; consider Docker secrets / a secrets manager for real deploys.
- **gunicorn** (26.x; its local control socket is switched off in `gunicorn.conf.py`) runs
`--workers 3 --timeout 60` by default (tune for your box;
`2 × CPU + 1` is a common starting point).
- **JWT refresh tokens** are revocable: logout (`POST /api/auth/logout/`) blacklists the
refresh token, and rotation blacklists the one it replaces (`token_blacklist` app). The
blacklist tables grow over time — prune expired rows periodically:
```bash
docker compose -f docker-compose.prod.yml exec backend python manage.py flushexpiredtokens
# e.g. weekly cron: 0 4 * * 0 cd /opt/timeline && docker compose -f docker-compose.prod.yml exec -T backend python manage.py flushexpiredtokens
```