Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Quality gates on every push and pull request, plus a weekly security scan of both images.
# A push to main that passes the backend and frontend gates is deployed to production.
name: CI

on:
Expand Down Expand Up @@ -81,3 +82,30 @@ jobs:
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"

deploy:
name: Deploy to production
runs-on: ubuntu-latest
needs: [backend, frontend]
# Pushes to main only, and only once the server is set up (repository variable DEPLOY_HOST;
# see docs/DEPLOY.md, "Automatic deploys"). Until then the job is skipped, not failed.
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_HOST != ''
environment: production # listed under the repository's Deployments, like Vercel
concurrency:
group: production
cancel-in-progress: false # never stop a deploy halfway; the next one waits
steps:
- name: Run deploy/deploy.sh on the server
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER || 'root' }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
# The server pins this key to deploy/deploy.sh in authorized_keys: it can run nothing else.
ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 \
"$DEPLOY_USER@$DEPLOY_HOST"
31 changes: 31 additions & 0 deletions 02-DOCS/wiki/ftd/continuous-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
title: Continuous deployment from main to the server
status: done
updated: 2026-09-25
---

# Continuous deployment

## Intent

Every push to `main` that passes CI reaches production without logging in to the server, like
Vercel, and a broken release never stays online.

## Design

- `deploy/deploy.sh` (on the server): lock, fetch `main`, tag the running images `:previous`,
fast-forward, build while the old version serves, `up -d`, wait for Docker health checks (web,
frontend; worker running), roll back images and commit if unhealthy after 240 s, prune.
- CI job `deploy`: needs backend and frontend, runs on pushes to `main` when the `DEPLOY_HOST`
variable exists, `production` environment, no cancellation mid-deploy. It only opens SSH.
- The SSH key is pinned in `authorized_keys` with `command=".../deploy.sh"` and no forwarding or
pty, and the host key is checked (`DEPLOY_KNOWN_HOSTS`): a leaked key can only redeploy `main`.

## Checklist

| # | Task | Proof | Status |
|---|------|-------|--------|
| 1 | Deploy script | Simulated `docker` in a throwaway git setup: nothing new → exit 0; healthy → deployed; never healthy → rolled back to the previous commit and images, exit 1; build fails → no restart; retry → deployed; not on main → refused | ✅ |
| 2 | CI deploy job | YAML parses; needs backend and frontend; skipped without `DEPLOY_HOST` | ✅ |
| 3 | Docs | DEPLOY sections 10 and "Automatic deploys" | ✅ |
| 4 | First real run on the server | Owner sets the key, secrets and variables | ⏳ |
1 change: 1 addition & 0 deletions 02-DOCS/wiki/sdd/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@
| 2026-09-25 | Count unique visitors with a keyed hash of IP and user agent in daily Redis HyperLogLogs, no cookies, totals only | Google Analytics or Plausible; a cookie; storing hashes in Postgres | No consent banner, no third party, and nothing that identifies a visitor is ever stored; about 0.8% counting error is acceptable |
| 2026-09-25 | Behind the host's Nginx, the container trusts X-Forwarded-For only from REAL_IP_FROM networks | Trusting the header always; TRUSTED_PROXIES=2 | Without it every visitor shares the gateway address (shared rate limits, admin lockout); trusting it from anywhere would let clients forge their address |
| 2026-09-25 | Sentences the model composes (summary, contract obligations, report findings) are written in the document's own language | Always English | Users read the summary of their own document; an English summary of a Spanish CV looked broken |
| 2026-09-25 | Deploy on push to main: CI opens SSH with a key pinned to deploy/deploy.sh, which builds on the server, health-checks and rolls back | A registry with prebuilt images; a polling timer on the server; Watchtower | No registry to run or pay for; the pinned key can do nothing but redeploy main; pushing from CI keeps deploys gated on tests and visible in GitHub's Deployments |
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@
| Feature: email verification, passwords, subscriptions, webhooks | `02-DOCS/wiki/ftd/account-lifecycle-billing.md` |
| Feature: per-page pricing, page packs, i18n, themes, landing redesign | `02-DOCS/wiki/ftd/pages-i18n-redesign.md` |
| Feature: unique visitor counter, real visitor addresses behind the host's Nginx | `02-DOCS/wiki/ftd/visitors-real-ip.md` |
| Feature: continuous deployment (push to main → server, with rollback) | `02-DOCS/wiki/ftd/continuous-deployment.md` |
| Production deployment guide | `docs/DEPLOY.md` |
101 changes: 101 additions & 0 deletions deploy/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Deploy the latest main on this server: pull, build, restart, wait until healthy, and roll back
# to the previous version if the new one does not come up.
#
# deploy/deploy.sh deploy if origin/main has new commits
# deploy/deploy.sh --force rebuild and restart even when nothing changed
#
# GitHub Actions runs it over SSH after CI passes on main (see docs/DEPLOY.md, "Automatic
# deploys"); it can also be run by hand. Only one deploy runs at a time. Database migrations run
# when the web container starts and are not undone by a rollback, so keep them backward
# compatible (add columns and tables; drop them in a later release).
set -euo pipefail

BRANCH=main
IMAGES=(pdf-process-pipeline extracta-frontend)
HEALTH_TIMEOUT=${DEPLOY_HEALTH_TIMEOUT:-240} # seconds; Docker health checks run every 30 s
HEALTH_POLL=${DEPLOY_HEALTH_POLL:-5}

cd "$(dirname "$0")/.."

log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; }
fail() {
log "ERROR: $*"
exit 1
}

# One deploy at a time: a second push waits for the first deploy, then deploys what is newest.
if command -v flock >/dev/null 2>&1; then
exec 9>/tmp/extracta-deploy.lock
flock -w 1800 9 || fail "another deploy has been running for 30 minutes"
fi

[[ -f .env ]] || fail ".env is missing: copy .env.sample and fill it in first"
[[ "$(git rev-parse --abbrev-ref HEAD)" == "$BRANCH" ]] || fail "this checkout is not on $BRANCH"

git fetch --quiet origin "$BRANCH"
old=$(git rev-parse HEAD)
new=$(git rev-parse "origin/$BRANCH")
if [[ "$old" == "$new" && "${1:-}" != "--force" ]]; then
log "already at ${new:0:7}: nothing to deploy"
exit 0
fi

log "deploying ${old:0:7} -> ${new:0:7}"
git log --oneline "$old..$new" | sed 's/^/ /'

# Keep the running images as :previous so a failed deploy can go back to them in seconds.
for image in "${IMAGES[@]}"; do
if docker image inspect "$image:latest" >/dev/null 2>&1; then
docker image tag "$image:latest" "$image:previous"
fi
done

git merge --ff-only --quiet "origin/$BRANCH" || fail "cannot fast-forward to origin/$BRANCH (local changes on the server?)"

log "building images (the site keeps running the old version meanwhile)"
if ! docker compose build; then
git reset --hard --quiet "$old"
fail "build failed: nothing was restarted, the server still runs ${old:0:7}"
fi

log "starting the new containers"
docker compose up -d --remove-orphans

healthy() {
local service id status
for service in web frontend; do
id=$(docker compose ps -q "$service")
[[ -n "$id" ]] || return 1
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$id")
[[ "$status" == "healthy" ]] || return 1
done
id=$(docker compose ps -q worker) # no health check: it must at least be running
[[ -n "$id" && "$(docker inspect -f '{{.State.Running}}' "$id")" == "true" ]]
}

log "waiting for web, frontend and worker to be healthy (up to ${HEALTH_TIMEOUT}s)"
waited=0
until healthy; do
if ((waited >= HEALTH_TIMEOUT)); then
log "the new version did not become healthy; last web logs:"
docker compose logs --tail 30 web | sed 's/^/ /' || true
log "rolling back to ${old:0:7}"
git reset --hard --quiet "$old"
for image in "${IMAGES[@]}"; do
if docker image inspect "$image:previous" >/dev/null 2>&1; then
docker image tag "$image:previous" "$image:latest"
fi
done
docker compose up -d --remove-orphans
fail "deploy of ${new:0:7} failed and was rolled back to ${old:0:7}"
fi
sleep "$HEALTH_POLL"
waited=$((waited + HEALTH_POLL))
done

# Free disk: untagged images from older builds and week-old build cache. The :previous images stay
# for the next rollback.
docker image prune --force >/dev/null
docker builder prune --force --filter until=168h >/dev/null
log "deployed ${new:0:7} in production"
43 changes: 37 additions & 6 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,14 +254,45 @@ an event was lost. A refund made from PayPal's dashboard ends the plan time it p

```bash
cd /opt/extracta
git pull
docker compose build
docker compose up -d
./scripts/docker-cleanup.sh
./deploy/deploy.sh
```

Migrations run automatically on start. Rebuilding also installs the latest Debian security
patches into the images.
It pulls `main`, builds the images while the old version keeps serving, restarts the containers
and waits until `web`, `frontend` and `worker` are healthy. If the new version does not come up
within 4 minutes it goes back to the previous images and commit on its own. A build failure
restarts nothing. `./deploy/deploy.sh --force` rebuilds even when `main` has not changed (for
example, to pick up new Debian security patches). Migrations run automatically on start and are
not undone by a rollback, so keep them backward compatible.

### Automatic deploys (push to main → production)

The `deploy` job in `.github/workflows/ci.yml` runs `deploy/deploy.sh` on the server over SSH
after every push to `main` whose backend and frontend checks pass. It stays skipped until these
steps are done once.

1. **A key that can only deploy.** On the server:

```bash
ssh-keygen -t ed25519 -N "" -C github-actions-deploy -f ~/.ssh/extracta_deploy
echo "command=\"$PWD/deploy/deploy.sh\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty $(cat ~/.ssh/extracta_deploy.pub)" >> ~/.ssh/authorized_keys
```

The `command=` prefix pins the key to the deploy script: whoever holds it can trigger a deploy
of what is already on `main`, and nothing else. Run the `echo` from the project folder.
2. **The server's identity**, so GitHub refuses to talk to an impostor:

```bash
echo "YOUR_SERVER_IP $(cut -d' ' -f1,2 /etc/ssh/ssh_host_ed25519_key.pub)"
```

3. **In GitHub** → the repository → **Settings → Secrets and variables → Actions**:
- Secrets: `DEPLOY_SSH_KEY` = the whole output of `cat ~/.ssh/extracta_deploy` (then delete
that file from the server: `rm ~/.ssh/extracta_deploy`); `DEPLOY_KNOWN_HOSTS` = the line
from step 2.
- Variables: `DEPLOY_HOST` = the server's IP; `DEPLOY_USER` = `root` (or the user that owns the
project folder).
4. Push to `main`. **Actions** shows the run, and **Deployments → production** keeps the history.
A red deploy job means the server kept (or went back to) the previous version: its log says why.

## 11. Operations

Expand Down
Loading