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.
8.9 KiB
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 passwordsLOCFLOW_SECRET_KEY,EIRESCOPE_SECRET_KEY—python3 -c "from secrets import token_urlsafe; print(token_urlsafe(50))"MYSQL_ROOT_PASSWORD,WP_DB_PASSWORD— for WordPressGRAFANA_ADMIN_PASSWORDCROWDSEC_BOUNCER_KEY— generated after first CrowdSec startup (see step 6)AUTHENTIK_SECRET_KEY,AUTHENTIK_DB_PASSWORDFORGEJO_DB_PASSWORD,FORGEJO_SECRET_KEY,FORGEJO_INTERNAL_TOKEN,FORGEJO_OAUTH2_JWT_SECRET— see Forgejo section below for generation commandsFORGEJO_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:
- Generate a registration token inside the Forgejo container:
docker exec -u 1000 forgejo forgejo forgejo-cli actions generate-runner-token - Save it to
infrastructure/.envasFORGEJO_RUNNER_REGISTRATION_TOKEN. - Recreate the runner:
docker compose up -d --force-recreate forgejo-runner. It auto-registers viaforgejo-runner/entrypoint.sh. - 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 internalforgejohostname 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 ~30–60s.
Updating
Blog content (richardnixon.dev): edit Markdown in the Hugo repo, git push origin main — Forgejo Actions builds and deploys automatically (~30–60s).
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)
-
In Authentik admin (
auth.richardnixon.dev), create an OAuth2/OpenID Provider with redirect URIhttps://git.richardnixon.dev/user/oauth2/Authentik/callback(theAuthentiksegment is the source name in step 4 — case-sensitive). -
Create an Application bound to that provider with slug
forgejo. -
Copy the Client ID and Client Secret into
.envasFORGEJO_OIDC_CLIENT_ID/FORGEJO_OIDC_CLIENT_SECRET. -
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.