ci: update compose.yml for prod deployment

- Add Nginx as a reverse proxy
- Add cert-selfsign.sh for generating self-signed certificates
- Add certbot-issue.sh for Let's Encrypt certificate issuance and renewal
- Add cron-install.sh and cron-uninstall.sh for system cron management
- Add .env.example as an environment variable template
This commit is contained in:
Hang
2026-08-23 23:10:30 +01:00
parent 52c504a149
commit c1ac0065ee
10 changed files with 270 additions and 26 deletions

View File

@@ -23,12 +23,10 @@ nosetests.xml
coverage.xml
*.cover
*.log
.git
.gitignore
.mypy_cache
.pytest_cache
.hypothesis
.env
.venv
env/
venv/
@@ -43,8 +41,12 @@ venv.bak/
*~
docs/
tests/
*.md
docker-compose*.yml
Dockerfile*
.dockerignore
pipeline/built_assets/
nginx/
*.pem
*.key
.env.*
scripts/

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
SECRET_KEY=
DEBUG=false
STAGING=false
DJANGO_ALLOWED_HOSTS=
DOMAIN=
SESSION_COOKIE_SECURE_ENABLED=true
CSRF_COOKIE_SECURE_ENABLED=true
DATABASE_ENGINE=postgresql
DATABASE_NAME=rigs
DATABASE_USERNAME=rigs
DATABASE_PASSWORD=
DATABASE_HOST=db
DATABASE_PORT=5432
EMAIL_HOST=
EMAIL_PORT=587
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=
EMAIL_USE_SSL=
EMAIL_FROM=
HCAPTCHA_SITEKEY=
HCAPTCHA_SECRET=
SENTRY_DSN=

11
.gitignore vendored
View File

@@ -104,4 +104,13 @@ screenshots/
# Virutal Environments
.venv/
/.env
# Environment variable files (keep .env.example)
.env
.env.*
!.env.example
nginx/certs/
nginx/certbot/
*.pem
*.key

View File

@@ -13,8 +13,7 @@ from RIGS import models
class Command(BaseCommand):
# FIXME This needs a different implementation when moved off heroku
help = 'Sends email reminders as required. Triggered daily through heroku-scheduler in production.'
help = 'Sends email reminders as required. Triggered daily through sys cron in production.'
def handle(self, *args, **options):
events = models.Event.objects.current_events().select_related('riskassessment')

View File

@@ -1,41 +1,67 @@
services:
db:
image: postgres:17
image: postgres:18.6
restart: unless-stopped
environment:
POSTGRES_DB: ${DATABASE_NAME}
POSTGRES_USER: ${DATABASE_USERNAME}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
env_file:
- .env
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
interval: 10s
timeout: 5s
retries: 5
pyrigs:
build: .
container_name: pyrigs
ports:
- "8000:8000"
depends_on:
- db
restart: unless-stopped
environment:
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
DEBUG: ${DEBUG}
DJANGO_LOGLEVEL: ${DJANGO_LOGLEVEL}
SECRET_KEY: ${SECRET_KEY}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
DATABASE_ENGINE: ${DATABASE_ENGINE}
DOMAIN: ${DOMAIN:-example.com}
DEBUG: ${DEBUG:-false}
STAGING: ${STAGING:-false}
SESSION_COOKIE_SECURE_ENABLED: ${SESSION_COOKIE_SECURE_ENABLED:-true}
CSRF_COOKIE_SECURE_ENABLED: ${CSRF_COOKIE_SECURE_ENABLED:-true}
DATABASE_ENGINE: ${DATABASE_ENGINE:-postgresql}
DATABASE_NAME: ${DATABASE_NAME}
DATABASE_USERNAME: ${DATABASE_USERNAME}
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
DATABASE_HOST: ${DATABASE_HOST}
DATABASE_PORT: ${DATABASE_PORT}
env_file:
- .env
DATABASE_HOST: ${DATABASE_HOST:-db}
DATABASE_PORT: ${DATABASE_PORT:-5432}
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_PORT: ${EMAIL_PORT:-25}
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-false}
EMAIL_USE_SSL: ${EMAIL_USE_SSL:-false}
EMAIL_FROM: ${EMAIL_FROM}
HCAPTCHA_SITEKEY: ${HCAPTCHA_SITEKEY:-10000000-ffff-ffff-ffff-000000000001}
HCAPTCHA_SECRET: ${HCAPTCHA_SECRET:-0x0000000000000000000000000000000000000000}
SENTRY_DSN: ${SENTRY_DSN:-}
expose:
- "8000"
depends_on:
db:
condition: service_healthy
command:
- sh
- -c
- |
python manage.py migrate --noinput &&
gunicorn \
--bind 0.0.0.0:8000 \
--workers 2 \
PyRIGS.wsgi
develop:
# Create a `watch` configuration to update the app
#
watch:
# Sync the working directory with the `/app` directory in the container
- action: sync
@@ -48,5 +74,21 @@ services:
# Rebuild the image on changes to the `pyproject.toml`
- action: rebuild
path: ./pyproject.toml
nginx:
image: nginx:stable-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
- ./nginx/certbot:/var/www/certbot:ro
depends_on:
- pyrigs
volumes:
postgres_data:

View File

@@ -0,0 +1,38 @@
upstream pyrigs {
server pyrigs:8000;
}
server {
listen 80;
server_name _;
# Let's Encrypt HTTP-01
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name _;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 20m;
location / {
proxy_pass http://pyrigs;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_redirect off;
}
}

28
scripts/cert-selfsign.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Usage: ./scripts/cert-selfsign.sh [domain] (no root required)
set -euo pipefail
DOMAIN="${1:-localhost}"
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CERTS_DIR="$REPO_DIR/nginx/certs"
mkdir -p "$CERTS_DIR"
if [ -f "$CERTS_DIR/fullchain.pem" ]; then
read -rp "$CERTS_DIR/fullchain.pem already exists, overwrite? [y/N] " ans
case "$ans" in
y|Y) ;;
*) echo "Aborted"; exit 0 ;;
esac
fi
openssl req -x509 -nodes -newkey rsa:2048 -days 30 \
-keyout "$CERTS_DIR/privkey.pem" \
-out "$CERTS_DIR/fullchain.pem" \
-subj "/CN=$DOMAIN" \
-addext "subjectAltName=DNS:$DOMAIN" \
2>/dev/null
chmod 600 "$CERTS_DIR/privkey.pem"
echo "Done: self-signed certificate (valid for 30 days) generated in $CERTS_DIR"
echo "Next: start the stack with 'docker compose up -d', then run scripts/certbot-issue.sh to switch to the real certificate"

47
scripts/certbot-issue.sh Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Issue the initial certificate (run as root on the deployment server)
# Usage: sudo ./scripts/certbot-issue.sh <domain> <email>
set -euo pipefail
DOMAIN="${1:?Usage: $0 <domain> <email>}"
EMAIL="${2:?Usage: $0 <domain> <email>}"
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
WEBROOT="$REPO_DIR/nginx/certbot"
CERTS_DIR="$REPO_DIR/nginx/certs"
HOOK_PATH="/etc/letsencrypt/renewal-hooks/deploy/pyrigs-deploy.sh"
if ! command -v certbot >/dev/null 2>&1; then
echo "certbot not found. Install it first: apt install certbot (or snap install certbot --classic)" >&2
exit 1
fi
mkdir -p "$WEBROOT" "$CERTS_DIR" /etc/letsencrypt/renewal-hooks/deploy
# 1. Initial issuance (certbot skips if the cert already exists and is not due for renewal)
certbot certonly \
--webroot -w "$WEBROOT" \
-d "$DOMAIN" \
-m "$EMAIL" \
--agree-tos --no-eff-email --non-interactive
# 2. Install deploy-hook (placed in the official dir, triggered automatically on successful renewal)
cat > "$HOOK_PATH" <<EOF
#!/bin/sh
# Generated by scripts/certbot-issue.sh: copy renewed certs and reload nginx
set -e
cp -fL "\$RENEWED_LINEAGE/fullchain.pem" "$CERTS_DIR/fullchain.pem"
cp -fL "\$RENEWED_LINEAGE/privkey.pem" "$CERTS_DIR/privkey.pem"
chmod 600 "$CERTS_DIR/privkey.pem"
docker compose -f "$REPO_DIR/compose.yml" exec -T nginx nginx -s reload || true
echo "pyrigs deploy-hook: \$(date) renewed [\$RENEWED_DOMAINS] and reloaded nginx"
EOF
chmod +x "$HOOK_PATH"
# 3. Deploy the freshly issued certificate right away
RENEWED_LINEAGE="/etc/letsencrypt/live/$DOMAIN" \
RENEWED_DOMAINS="$DOMAIN" \
"$HOOK_PATH"
echo "Done: certificate for $DOMAIN issued and deployed to $CERTS_DIR; deploy-hook installed."
echo "Tip: for scheduled renewal, run sudo ./scripts/cron-install.sh"

34
scripts/cron-install.sh Normal file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Install system-level scheduled tasks (run as root on the deployment server)
# Usage: sudo ./scripts/cron-install.sh
#
# Writes two /etc/cron.d files:
# 1. pyrigs-certbot - certificate renewal check, daily at 03:00 / 15:00
# 2. pyrigs-django - cleanup and reminder jobs inside the pyrigs container
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CERTBOT_CRON="/etc/cron.d/pyrigs-certbot"
DJANGO_CRON="/etc/cron.d/pyrigs-django"
# 1. Certificate renewal: checked twice a day (officially recommended frequency);
# on successful renewal the deploy-hook in the official hook dir takes over
cat > "$CERTBOT_CRON" <<EOF
# Generated by scripts/cron-install.sh; remove with scripts/cron-uninstall.sh
0 3,15 * * * root certbot renew --quiet
EOF
# 2. Django scheduled tasks (replacing the former Heroku Scheduler jobs, run via exec inside the container)
cat > "$DJANGO_CRON" <<EOF
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Generated by scripts/cron-install.sh; remove with scripts/cron-uninstall.sh
0 0 * * * root docker compose -f $REPO_DIR/compose.yml exec -T pyrigs sh -c "python manage.py cleanupregistration && python manage.py usercleanup" >> /var/log/pyrigs-cleanup.log 2>&1
0 8 * * * root docker compose -f $REPO_DIR/compose.yml exec -T pyrigs python manage.py send_reminders >> /var/log/pyrigs-reminders.log 2>&1
EOF
chmod 644 "$CERTBOT_CRON" "$DJANGO_CRON"
echo "Installed:"
echo " $CERTBOT_CRON"
echo " $DJANGO_CRON"

19
scripts/cron-uninstall.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Uninstall the scheduled tasks installed by cron-install.sh (run as root on the deployment server)
# Usage: sudo ./scripts/cron-uninstall.sh
set -euo pipefail
CERTBOT_CRON="/etc/cron.d/pyrigs-certbot"
DJANGO_CRON="/etc/cron.d/pyrigs-django"
HOOK_PATH="/etc/letsencrypt/renewal-hooks/deploy/pyrigs-deploy.sh"
rm -f "$CERTBOT_CRON" "$DJANGO_CRON"
echo "Removed scheduled tasks: $CERTBOT_CRON $DJANGO_CRON"
if [ -f "$HOOK_PATH" ]; then
read -rp "Also remove the deploy-hook ($HOOK_PATH)? [y/N] " ans
case "$ans" in
y|Y) rm -f "$HOOK_PATH"; echo "Deploy-hook removed" ;;
*) echo "Deploy-hook kept" ;;
esac
fi