richardnixon.dev/docs/deployment.md
Richard Nixon a87db639eb
Some checks failed
CI/CD / ci (push) Failing after 1m2s
CI/CD / deploy (push) Has been skipped
chore: remove legacy Django app and runtime
The public blog at richardnixon.dev is fully served by the Hugo
companion repo (richardnixon.dev-hugo) since the cutover. This commit
drops everything Django-related from this repo:

- Containers stopped and removed (already done on the host):
  platform-web, platform-celery, platform-celery-beat,
  platform-frontend, platform-db, platform-redis.
- Volumes dropped: platform-db-data, platform-redis-data,
  platform-static, platform-media (no backup; DB only held an empty draft).
- docker-compose.yml: removed all platform-* services, the
  platform-internal network, and the postgres-exporter / redis-exporter
  that only monitored the dropped DB/Redis pair.
- traefik/dynamic.yml: removed platform-api router and the
  platform-api / platform-frontend service definitions.
- Source tree: deleted apps/, config/, docker/, frontend/, locale/,
  templates/, media/, static/, requirements/, manage.py,
  requirements.txt, conftest.py, pytest.ini.
- docs/development.md removed (was 100% Django dev guide).
- README and docs/deployment.md rewritten as infra-only references.
2026-05-27 17:41:28 +02:00

8.9 KiB
Raw Permalink Blame History

Deployment Guide

Step-by-step guide to deploy richardnixon.dev on a fresh VPS.

Prerequisites

  • Ubuntu 22.04+ VPS with root access
  • Domain DNS pointing to VPS IP (A records for richardnixon.dev, *.richardnixon.dev)
  • Docker Engine 24+ and Docker Compose v2

1. Install Docker

curl -fsSL https://get.docker.com | sh

2. Clone the repository

cd /root
git clone https://git.richardnixon.dev/Richard/richardnixon.dev.git
cd richardnixon.dev

For the public blog content (Hugo + PaperMod):

cd /root
git clone --recurse-submodules https://git.richardnixon.dev/Richard/richardnixon.dev-hugo.git

The blog-static service in the infra compose expects this checkout at /root/richardnixon.dev-hugo/ (bind-mounts public/ into the nginx container).

3. Configure environment

cp infrastructure/.env.example infrastructure/.env

Edit infrastructure/.env and fill in all required values. At minimum:

  • LOCFLOW_DB_PASSWORD, UMAMI_DB_PASSWORD — strong random passwords
  • LOCFLOW_SECRET_KEY, EIRESCOPE_SECRET_KEYpython3 -c "from secrets import token_urlsafe; print(token_urlsafe(50))"
  • MYSQL_ROOT_PASSWORD, WP_DB_PASSWORD — for WordPress
  • GRAFANA_ADMIN_PASSWORD
  • CROWDSEC_BOUNCER_KEY — generated after first CrowdSec startup (see step 6)
  • AUTHENTIK_SECRET_KEY, AUTHENTIK_DB_PASSWORD
  • FORGEJO_DB_PASSWORD, FORGEJO_SECRET_KEY, FORGEJO_INTERNAL_TOKEN, FORGEJO_OAUTH2_JWT_SECRET — see Forgejo section below for generation commands
  • FORGEJO_RUNNER_REGISTRATION_TOKEN — generated after first Forgejo boot (see step 8)

4. Start core services

cd infrastructure
docker compose up -d

This starts every service: Traefik, Hugo blog-static, Forgejo + runner, WordPress, Umami, Grafana, Prometheus, Loki, Promtail, LocFlow, EireScope, Authentik, CrowdSec.

Traefik automatically provisions Let's Encrypt SSL certificates.

5. Configure CrowdSec bouncer

docker compose exec crowdsec cscli bouncers add traefik-bouncer

Copy the generated key into infrastructure/.env as CROWDSEC_BOUNCER_KEY, then restart:

docker compose restart crowdsec-bouncer

6. Verify deployment

# Check all containers are healthy
docker compose ps

# Smoke test the public blog (served by Hugo blog-static)
curl -I https://richardnixon.dev/
curl -I https://richardnixon.dev/pt-br/

7. Set up Forgejo Actions CI/CD for the Hugo blog

A forgejo-runner container is included in the compose and runs jobs on the host's Docker socket. After the first docker compose up -d:

  1. Generate a registration token inside the Forgejo container:
    docker exec -u 1000 forgejo forgejo forgejo-cli actions generate-runner-token
    
  2. Save it to infrastructure/.env as FORGEJO_RUNNER_REGISTRATION_TOKEN.
  3. Recreate the runner: docker compose up -d --force-recreate forgejo-runner. It auto-registers via forgejo-runner/entrypoint.sh.
  4. Verify the runner appears as online at https://git.richardnixon.dev/-/admin/actions/runners.

The deploy workflow lives in the Hugo repo (richardnixon.dev-hugo/.forgejo/workflows/deploy.yml) — it builds Hugo in a catthehacker/ubuntu:act-22.04 container and copies the output into the blog-static bind-mount on the host. The runner config (infrastructure/forgejo-runner/config.yml) authorises only one host volume: /root/richardnixon.dev-hugo/public.

Key non-obvious config bits (see infrastructure/forgejo-runner/config.yml):

  • container.network: infrastructure_forgejo-internal — required so job containers can resolve the internal forgejo hostname for checkout.
  • container.docker_host: unix:///var/run/docker.sock"automatic" is not accepted by recent runner versions.
  • valid_volumes: ["/root/richardnixon.dev-hugo/public"] — the only mount the workflow is allowed to write to.

After a push to main on richardnixon.dev-hugo, the runner builds and the site is live within ~3060s.

Updating

Blog content (richardnixon.dev): edit Markdown in the Hugo repo, git push origin main — Forgejo Actions builds and deploys automatically (~3060s).

Infrastructure:

cd /root/richardnixon.dev
git pull origin main
docker compose -f infrastructure/docker-compose.yml build locflow-web eirescope locflow-frontend
docker compose -f infrastructure/docker-compose.yml up -d
docker compose -f infrastructure/docker-compose.yml exec -T locflow-web python manage.py migrate --no-input

Services and ports

All services are behind Traefik (ports 80/443). No service exposes ports directly to the internet.

Service Internal port URL
Hugo blog-static (nginx) 80 richardnixon.dev (catch-all)
LocFlow API 8000 locflow.richardnixon.dev
EireScope 5000 eirescope.richardnixon.dev
WordPress 80 richardemanu.com
Umami 3000 analytics.richardnixon.dev
Grafana 3000 status.richardnixon.dev
Portainer 9443 portainer.richardnixon.dev
Authentik 9000 auth.richardnixon.dev
Forgejo 3000 (HTTP), 2222 (SSH on host) git.richardnixon.dev

Forgejo (git.richardnixon.dev)

Self-hosted Git forge with public repository browsing and SSO via Authentik.

Generate secrets

echo "FORGEJO_DB_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)"
echo "FORGEJO_SECRET_KEY=$(openssl rand -base64 48 | tr -d '\n')"
echo "FORGEJO_INTERNAL_TOKEN=$(openssl rand -base64 64 | tr -d '\n')"
echo "FORGEJO_OAUTH2_JWT_SECRET=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')"

Paste the output into infrastructure/.env.

Bootstrap the admin user

Registration is disabled (DISABLE_REGISTRATION=true), so create the first admin via the CLI:

docker compose exec -u git forgejo forgejo admin user create \
  --username <yourname> --email <you@example.com> --admin \
  --password "$(openssl rand -base64 18)" --must-change-password=false

(admin is a reserved name in Forgejo — use a different username.)

SSH cloning

Forgejo SSH listens on host port 2222 (port 22 is used by the host's sshd). Clone URL format:

ssh://git@git.richardnixon.dev:2222/<user>/<repo>.git

Wiring up Authentik SSO (OIDC)

  1. In Authentik admin (auth.richardnixon.dev), create an OAuth2/OpenID Provider with redirect URI https://git.richardnixon.dev/user/oauth2/Authentik/callback (the Authentik segment is the source name in step 4 — case-sensitive).

  2. Create an Application bound to that provider with slug forgejo.

  3. Copy the Client ID and Client Secret into .env as FORGEJO_OIDC_CLIENT_ID / FORGEJO_OIDC_CLIENT_SECRET.

  4. Register the source in Forgejo via CLI:

    docker compose exec -u git forgejo forgejo admin auth add-oauth \
      --name Authentik \
      --provider openidConnect \
      --key "$FORGEJO_OIDC_CLIENT_ID" \
      --secret "$FORGEJO_OIDC_CLIENT_SECRET" \
      --auto-discover-url "https://auth.richardnixon.dev/application/o/forgejo/.well-known/openid-configuration" \
      --scopes "openid profile email"
    

The compose already sets oauth2_client.ENABLE_AUTO_REGISTRATION=true and ACCOUNT_LINKING=auto, so existing accounts whose email matches the Authentik user are linked automatically, and new Authentik users get a Forgejo account on first login.

Provisioning the provider via the Authentik shell (alternative to the admin UI):

docker compose exec authentik-server ak shell -c '
from authentik.flows.models import Flow
from authentik.providers.oauth2.models import OAuth2Provider, ClientTypes, IssuerMode, ScopeMapping, RedirectURI, RedirectURIMatchingMode
from authentik.core.models import Application
from authentik.crypto.models import CertificateKeyPair

flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
key = CertificateKeyPair.objects.exclude(key_data="").first()
scopes = ScopeMapping.objects.filter(managed__in=[
    "goauthentik.io/providers/oauth2/scope-openid",
    "goauthentik.io/providers/oauth2/scope-email",
    "goauthentik.io/providers/oauth2/scope-profile",
])
p, _ = OAuth2Provider.objects.update_or_create(name="Forgejo", defaults=dict(
    client_type=ClientTypes.CONFIDENTIAL, authorization_flow=flow, signing_key=key,
    issuer_mode=IssuerMode.PER_PROVIDER))
p.redirect_uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
    url="https://git.richardnixon.dev/user/oauth2/Authentik/callback")]
p.save()
p.property_mappings.set(scopes)
Application.objects.update_or_create(slug="forgejo", defaults=dict(
    name="Forgejo", provider=p, meta_launch_url="https://git.richardnixon.dev/"))
print("CLIENT_ID=" + p.client_id); print("CLIENT_SECRET=" + p.client_secret)
'

Observability

Forgejo metrics are exposed unauthenticated on the monitoring network at forgejo:3000/metrics and scraped automatically by Prometheus (job forgejo). Logs flow into Loki via Promtail like every other container.