From 3815c094a158ced9569fd09c4b9936b67603ce54 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 14 Jun 2026 01:39:43 +0200 Subject: [PATCH] chore(deploy): hardened prod compose, rollout script, and CI for asgard docker-compose.prod.yml targets the public VPS: Postgres is never published, the app binds to 127.0.0.1 (Caddy proxies it), and every service has a memory/CPU cap plus no-new-privileges / cap_drop / read-only rootfs. deploy/deploy.sh rolls out main with a host-side build (migrations run via the entrypoint). CI type-checks and builds the frontend and byte-compiles the backend on every push to main. --- .forgejo/workflows/ci.yml | 35 ++++++++++++++++++ deploy/README.md | 49 +++++++++++++++++++++++++ deploy/deploy.sh | 20 +++++++++++ docker-compose.prod.yml | 76 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 deploy/README.md create mode 100755 deploy/deploy.sh create mode 100644 docker-compose.prod.yml diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..8db4df1 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI +# Lightweight validation on every push to main: type-check + build the frontend and +# byte-compile the backend. The image build and rollout happen on the the VPS host +# (see deploy/README.md); this just catches breakage early. +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm ci + working-directory: frontend + - run: npx tsc --noEmit + working-directory: frontend + - run: npm run build + working-directory: frontend + + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r requirements.txt + working-directory: backend + - run: python -m compileall -q app + working-directory: backend diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..b5ac9de --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,49 @@ +# Deploying Subfeed to the public VPS (the VPS) + +The public test instance runs on the the VPS VPS at **https://subfeed.b1fr0st.eu**, behind +Caddy (which terminates TLS and reverse-proxies to the app on `127.0.0.1:8080`). + +## Layout on the VPS + +``` +/srv/subfeed/ + src/ git clone of this repo (build context + compose file) + .env secrets, mode 0600 — NEVER committed +``` + +The hardened compose is [`docker-compose.prod.yml`](../docker-compose.prod.yml): Postgres is +never published, the app binds to localhost only, and every service is memory/CPU-capped for +the small VPS. + +## First-time setup + +1. DNS `A`/`AAAA` for `subfeed` → the VPS (done via the your DNS provider CLI). +2. Caddy vhost block `subfeed.b1fr0st.eu { reverse_proxy 127.0.0.1:8080 }` (+ the shared + security-headers snippet), then reload Caddy. +3. `git clone` this repo to `/srv/subfeed/src`. +4. Create `/srv/subfeed/.env` (mode 0600) with the required keys — see + [`.env.example`](../.env.example). Generate the secrets: + - `SECRET_KEY`: `python -c "import secrets; print(secrets.token_urlsafe(48))"` + - `TOKEN_ENCRYPTION_KEY`: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` + - `POSTGRES_PASSWORD`: any long random string. + - `OAUTH_REDIRECT_URL=https://subfeed.b1fr0st.eu/auth/callback` + - `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `YOUTUBE_API_KEY` from Google Cloud Console. + - `ALLOWED_EMAILS` / `ADMIN_EMAILS` = the invited Google accounts. + + > The app refuses to start in production (an `https` redirect URL) if `SECRET_KEY` is the + > placeholder/too short or `TOKEN_ENCRYPTION_KEY` is missing — by design. + +## Rolling out a new version + +```bash +/srv/subfeed/src/deploy/deploy.sh +``` + +It pulls `main`, rebuilds the image on the host, and restarts the stack (migrations run +automatically via the entrypoint's `alembic upgrade head`). + +## CI + +`.forgejo/workflows/ci.yml` type-checks/builds the frontend and byte-compiles the backend on +every push to `main`. It does not auto-deploy; rollout is the script above. (A future +improvement: a Forgejo-triggered or watchtower-based auto-rollout.) diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..e35fc28 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Roll out the latest main branch to the public the VPS instance. +# +# Layout on the VPS: +# /srv/subfeed/src git clone of this repo (build context + compose file) +# /srv/subfeed/.env secrets, mode 0600 (never in git) +# +# Build runs on the host Docker daemon, so it isn't constrained by the CI runner's memory. +set -euo pipefail + +ROOT=/srv/subfeed +cd "$ROOT/src" + +git fetch --quiet origin +git checkout --quiet main +git pull --ff-only --quiet + +docker compose -f docker-compose.prod.yml --env-file "$ROOT/.env" up -d --build +docker image prune -f >/dev/null || true +docker compose -f docker-compose.prod.yml --env-file "$ROOT/.env" ps diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..469720f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,76 @@ +# Public VPS (the VPS) deployment — hardened, single-host, behind Caddy. +# +# Differs from docker-compose.server.yml (the home/LXC stack): the database is NEVER +# published, the app binds to 127.0.0.1 only (Caddy terminates TLS and reverse-proxies it), +# there is no apparmor:unconfined (this is a bare VPS, not Docker-in-LXC), and every service +# has a hard memory/CPU cap so the stack can't exhaust the small VPS. +# +# Secrets live OUTSIDE the repo, in an env file on the host. Deploy with: +# docker compose -f docker-compose.prod.yml --env-file /srv/subfeed/.env up -d --build +# +# Required keys in that env file: POSTGRES_PASSWORD, SECRET_KEY (>=32 chars), +# TOKEN_ENCRYPTION_KEY (Fernet), GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, +# OAUTH_REDIRECT_URL=https://subfeed.b1fr0st.eu/auth/callback, ALLOWED_EMAILS, ADMIN_EMAILS. + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-subfeed} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in the env file} + POSTGRES_DB: ${POSTGRES_DB:-subfeed} + volumes: + - subfeed_pgdata:/var/lib/postgresql/data + networks: [internal] + mem_limit: 512m + cpus: 0.75 + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-subfeed} -d ${POSTGRES_DB:-subfeed}"] + interval: 10s + timeout: 5s + retries: 12 + restart: unless-stopped + + api: + build: + context: . + dockerfile: Dockerfile + image: subfeed-api:local + env_file: + - /srv/subfeed/.env + environment: + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-subfeed} + # The public instance owns the background scheduler (single writer). + SCHEDULER_ENABLED: "true" + depends_on: + db: + condition: service_healthy + networks: [internal] + ports: + # Localhost only — Caddy (host network) reverse-proxies 443 -> here. Never public. + - "127.0.0.1:8080:8000" + mem_limit: 768m + cpus: 1.0 + pids_limit: 256 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + +volumes: + subfeed_pgdata: + +networks: + internal: