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.
This commit is contained in:
parent
2a6fde86a1
commit
3815c094a1
4 changed files with 180 additions and 0 deletions
35
.forgejo/workflows/ci.yml
Normal file
35
.forgejo/workflows/ci.yml
Normal file
|
|
@ -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
|
||||||
49
deploy/README.md
Normal file
49
deploy/README.md
Normal file
|
|
@ -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.)
|
||||||
20
deploy/deploy.sh
Executable file
20
deploy/deploy.sh
Executable file
|
|
@ -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
|
||||||
76
docker-compose.prod.yml
Normal file
76
docker-compose.prod.yml
Normal file
|
|
@ -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:
|
||||||
Loading…
Add table
Add a link
Reference in a new issue