infra: cutover to Hugo blog + Forgejo Actions runner
Some checks failed
CI/CD / ci (push) Failing after 3m39s
CI/CD / deploy (push) Has been skipped

- Add blog-static (nginx:alpine) service backed by bind-mount from
  /root/richardnixon.dev-hugo/public. Hugo blog now owns
  richardnixon.dev/* via Traefik dynamic.yml (priority 100).
- Narrow platform-api (Django legacy) to /api, /admin, /static, /media
  only; remove platform-frontend (Next.js) from routing.
- Add forgejo-runner service joining infrastructure_forgejo-internal
  network, with bind-mount config + entrypoint. Allowed valid_volume:
  /root/richardnixon.dev-hugo/public for CI deploys.
- Enable [actions] in Forgejo (FORGEJO__actions__ENABLED=true,
  DEFAULT_ACTIONS_URL=https://code.forgejo.org).
- Add export_hugo management command (HTML+frontmatter dump for the
  one-time content migration; left in tree for future re-runs).
- Update README and docs/deployment.md to reflect new public surface,
  CI flow, and Forgejo clone URL.
This commit is contained in:
authentik Default Admin 2026-05-27 17:25:02 +02:00
parent 9f90aca614
commit 1e8c7ccf6f
12 changed files with 340 additions and 35 deletions

3
.gitignore vendored
View file

@ -73,3 +73,6 @@ celerybeat.pid
# Secrets
*.pem
*.key
# Local Traefik backups (manual rollback safety nets)
infrastructure/traefik/*.bak-*

View file

@ -1,6 +1,8 @@
# richardnixon.dev
Personal portfolio and blog platform built with Django, deployed with Docker.
Self-hosted infrastructure (Traefik + Docker Compose) running every service on the `richardnixon.dev` apex and subdomains.
The public blog at `richardnixon.dev` is now a **static Hugo site** — its source lives in a separate repo: [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo). The Django app in `apps/` here is kept around as **legacy**: it still answers `/admin/`, `/api/`, `/media/`, `/static/` while the database is preserved, but its blog/portfolio/contact views are no longer the public surface.
## Architecture
@ -9,7 +11,8 @@ graph TD
DNS[Cloudflare DNS] --> Traefik[Traefik + SSL/TLS]
Traefik --> CrowdSec[CrowdSec IPS]
Traefik --> Platform[Django Platform<br>richardnixon.dev]
Traefik --> Blog[Hugo blog-static<br>richardnixon.dev]
Traefik --> Platform[Django legacy<br>richardnixon.dev/admin·/api]
Traefik --> WP[WordPress<br>richardemanu.com]
Traefik --> LocFlow[LocFlow API<br>locflow.richardnixon.dev]
Traefik --> EireScope[EireScope<br>eirescope.richardnixon.dev]
@ -32,11 +35,13 @@ graph TD
| Service | Domain | Description |
|---------|--------|-------------|
| Django Platform | richardnixon.dev | Blog, portfolio, contact |
| Hugo blog (blog-static) | richardnixon.dev | Static blog (Hugo + PaperMod) — content in [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo) |
| Django legacy (platform-web) | richardnixon.dev/admin · /api | Admin + API for migration window; will be retired |
| Forgejo Actions runner | (internal) | CI runner for Hugo blog deploy (joins `forgejo-internal` network) |
| WordPress | richardemanu.com | Personal site |
| LocFlow | locflow.richardnixon.dev | Localization automation platform (REST API) |
| EireScope | eirescope.richardnixon.dev | OSINT dashboard |
| Forgejo | git.richardnixon.dev | Self-hosted Git forge (public repos, SSO via Authentik) |
| Forgejo | git.richardnixon.dev | Self-hosted Git forge (public repos, SSO via Authentik, Actions enabled) |
| Umami | analytics.richardnixon.dev | Privacy-focused analytics |
| Grafana | status.richardnixon.dev | Observability dashboards |
| Portainer | portainer.richardnixon.dev | Docker management |
@ -45,11 +50,18 @@ graph TD
## Technology Stack
### Application
### Public blog (`richardnixon.dev`)
- **Generator**: Hugo 0.123.7 extended + PaperMod theme (pinned `v8.0`)
- **Server**: nginx:alpine (`blog-static` service), volume bind-mounted from `/root/richardnixon.dev-hugo/public/`
- **CI**: Forgejo Actions on push to `main` of [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo)
- **i18n**: PT-BR (default) + EN, native Hugo language config
### Legacy Django app (kept for `/admin` and `/api` only)
- **Backend**: Django 5.x, Celery, PostgreSQL 16, Redis 7
- **Frontend**: Django Templates, HTMX, TailwindCSS
- **Auth**: django-allauth (Google/GitHub OAuth), django-two-factor-auth (2FA)
- **i18n**: English + Portuguese (django-modeltranslation)
- **Routes**: limited to `/api`, `/admin`, `/static`, `/media` via Traefik dynamic.yml (priority 110); blog/portfolio/contact views are dormant.
### Infrastructure
- **Reverse Proxy**: Traefik v3 with Let's Encrypt SSL
@ -137,10 +149,15 @@ richardnixon.dev/
1. Clone the repository:
```bash
git clone https://github.com/richardnixondev/richardnixon.dev.git
git clone https://git.richardnixon.dev/Richard/richardnixon.dev.git
cd richardnixon.dev/infrastructure
```
For the public blog content (Hugo source), clone the companion repo:
```bash
git clone --recurse-submodules https://git.richardnixon.dev/Richard/richardnixon.dev-hugo.git
```
2. Create environment file:
```bash
cp .env.example .env
@ -376,17 +393,18 @@ docker exec valheim backup
docker logs valheim 2>&1 | grep "Got connection"
```
## URL Routes
## URL Routes (post-Hugo cutover)
| Path | Description |
|------|-------------|
| `/` | Blog home |
| `/admin/` | Django admin (2FA enabled) |
| `/accounts/` | Authentication (OAuth) |
| `/portfolio/` | Projects showcase |
| `/contact/` | Contact form (reCAPTCHA) |
| `/sitemap.xml` | XML sitemap |
| `/feed/` | RSS feed |
Routing is configured in `infrastructure/traefik/dynamic.yml`.
| Path | Backend | Notes |
|------|---------|-------|
| `/` | blog-static (Hugo) | Redirects to `/pt-br/` (default content language) |
| `/pt-br/...`, `/en/...` | blog-static (Hugo) | Bilingual blog content |
| `/feed.xml`, `/sitemap.xml` | blog-static (Hugo) | Per-language RSS and sitemap index |
| `/admin/` | platform-web (Django) | Admin interface (2FA) — priority 110 |
| `/api/` | platform-web (Django) | REST stub for legacy clients |
| `/static/`, `/media/` | platform-web (Django) | Legacy static/media uploads |
## License

View file

View file

@ -0,0 +1,131 @@
"""Export BlogPost rows to Hugo-ready HTML files with YAML frontmatter.
Output tree:
<out>/pt-br/posts/<slug>.html
<out>/en/posts/<slug>.html
Posts with empty content for a given language are skipped silently.
Frontmatter contains: title, date, slug, tags, description, draft.
"""
import json
from pathlib import Path
from django.core.management.base import BaseCommand
from apps.blog.models import BlogPost
LANGS = ("pt-br", "en")
def lang_attr(post, field, lang):
"""Read django-modeltranslation field, e.g. title_en / content_pt_br."""
suffix = lang.replace("-", "_")
return getattr(post, f"{field}_{suffix}", None)
def safe_title(value, fallback):
cleaned = (value or "").strip()
return cleaned or fallback
def build_frontmatter(post, lang):
title = safe_title(lang_attr(post, "title", lang), post.title)
description = (lang_attr(post, "meta_description", lang) or post.meta_description or "").strip()
if not description:
excerpt = (lang_attr(post, "excerpt", lang) or post.excerpt or "").strip()
description = excerpt[:200]
date = post.published_at or post.created_at
tags = sorted(post.tags.values_list("name", flat=True))
return {
"title": title,
"date": date.isoformat() if date else None,
"slug": post.slug,
"tags": list(tags),
"description": description,
"draft": post.status != BlogPost.Status.PUBLISHED,
}
def _yaml_scalar(value):
"""Quote scalars safely; bare-emit None as empty string."""
if value is None:
return '""'
s = str(value)
return json.dumps(s, ensure_ascii=False)
def render_file(frontmatter, html_body):
lines = ["---"]
for key, value in frontmatter.items():
if isinstance(value, list):
if not value:
lines.append(f"{key}: []")
else:
lines.append(f"{key}:")
for item in value:
lines.append(f" - {_yaml_scalar(item)}")
elif isinstance(value, bool):
lines.append(f"{key}: {'true' if value else 'false'}")
else:
lines.append(f"{key}: {_yaml_scalar(value)}")
lines.append("---")
lines.append("")
return "\n".join(lines) + "\n" + html_body + "\n"
class Command(BaseCommand):
help = "Export published blog posts to Hugo-compatible HTML files."
def add_arguments(self, parser):
parser.add_argument(
"--out",
default="/tmp/hugo-export",
help="Output directory (default: /tmp/hugo-export)",
)
parser.add_argument(
"--include-private",
action="store_true",
help="Include private posts (default: only public).",
)
parser.add_argument(
"--include-drafts",
action="store_true",
help="Include drafts (marks them draft:true in frontmatter).",
)
def handle(self, *args, **opts):
out_root = Path(opts["out"])
if opts["include_drafts"]:
qs = BlogPost.objects.all()
else:
qs = BlogPost.objects.filter(status=BlogPost.Status.PUBLISHED)
if not opts["include_private"]:
qs = qs.filter(is_private=False)
written = {lang: 0 for lang in LANGS}
skipped = {lang: 0 for lang in LANGS}
for post in qs.iterator():
for lang in LANGS:
content = lang_attr(post, "content", lang) or ""
if not content.strip():
skipped[lang] += 1
continue
fm = build_frontmatter(post, lang)
rendered = render_file(fm, content)
target = out_root / lang / "posts" / f"{post.slug}.html"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(rendered, encoding="utf-8")
written[lang] += 1
for lang in LANGS:
self.stdout.write(
f"[{lang}] wrote {written[lang]} files, skipped {skipped[lang]} (empty)"
)
self.stdout.write(self.style.SUCCESS(f"Done -> {out_root}"))

View file

@ -21,6 +21,7 @@ ALLOWED_HOSTS = [
'richardnixon.dev',
'www.richardnixon.dev',
'localhost',
'platform-web',
]
# CSRF trusted origins for Traefik proxy

View file

@ -18,10 +18,17 @@ curl -fsSL https://get.docker.com | sh
```bash
cd /root
git clone git@github.com:richardnixondev/richardnixon.dev.git
git clone https://git.richardnixon.dev/Richard/richardnixon.dev.git
cd richardnixon.dev
```
For the public blog content (Hugo + PaperMod):
```bash
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
```bash
@ -48,7 +55,7 @@ cd infrastructure
docker compose up -d
```
This starts all services: Traefik, PostgreSQL, Redis, Django, Celery, WordPress, Umami, Grafana, Prometheus, LocFlow, EireScope, Authentik, Forgejo.
This starts all services: Traefik, PostgreSQL, Redis, Django (legacy), Celery (legacy), Hugo blog-static, Forgejo + runner, WordPress, Umami, Grafana, Prometheus, LocFlow, EireScope, Authentik.
Traefik automatically provisions Let's Encrypt SSL certificates.
@ -89,22 +96,32 @@ curl https://richardnixon.dev/health/
2. Under **Social applications**, add Google and/or GitHub OAuth providers
3. Get credentials from Google Cloud Console / GitHub Developer Settings
## 9. Set up GitHub Actions CI/CD (optional)
## 9. Set up Forgejo Actions CI/CD for the Hugo blog
Add the following secrets to the GitHub repository (Settings > Secrets > Actions):
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`:
| Secret | Value |
|--------|-------|
| `VPS_HOST` | VPS IP address |
| `VPS_USER` | `root` |
| `VPS_SSH_KEY` | SSH private key |
| `DJANGO_SECRET_KEY` | Same as in `.env` |
1. Generate a registration token inside the Forgejo container:
```bash
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`.
After this, every push to `main` will automatically deploy.
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
Manual update:
**Blog content** (`richardnixon.dev`): edit Markdown in the Hugo repo, `git push origin main`, Forgejo Actions deploys automatically. See `Richard/richardnixon.dev-hugo/.forgejo/workflows/deploy.yml`.
**Infrastructure + legacy Django**:
```bash
cd /root/richardnixon.dev
@ -121,8 +138,8 @@ All services are behind Traefik (ports 80/443). No service exposes ports directl
| Service | Internal port | URL |
|---------|--------------|-----|
| Django platform | 8000 | richardnixon.dev |
| Next.js frontend | 3000 | richardnixon.dev (via Traefik routing) |
| Hugo blog-static (nginx) | 80 | richardnixon.dev (catch-all, priority 100) |
| Django platform (legacy) | 8000 | richardnixon.dev/{admin,api,static,media} (priority 110) |
| LocFlow API | 8000 | locflow.richardnixon.dev |
| EireScope | 5000 | osint.richardnixon.dev |
| WordPress | 80 | richardemanu.com |

View file

@ -0,0 +1,38 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
gzip on;
gzip_types text/plain text/css text/xml application/xml application/rss+xml application/atom+xml application/json application/javascript;
gzip_min_length 256;
location / {
try_files $uri $uri/ $uri/index.html =404;
}
location ~* \.(css|js|woff2?|ttf|otf|svg|ico|png|jpe?g|gif|webp|avif)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
location ~* \.(xml|rss)$ {
expires 1h;
add_header Cache-Control "public, max-age=3600";
}
location ~* \.html$ {
expires 5m;
add_header Cache-Control "public, max-age=300";
}
location = /robots.txt { access_log off; log_not_found off; }
location = /favicon.ico { access_log off; log_not_found off; }
server_tokens off;
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
add_header Referrer-Policy "strict-origin-when-cross-origin";
}

View file

@ -215,6 +215,49 @@ services:
networks:
- platform-internal
# ===========================================
# FORGEJO RUNNER - CI for self-hosted git
# ===========================================
forgejo-runner:
image: code.forgejo.org/forgejo/runner:6.2.2
container_name: forgejo-runner
restart: unless-stopped
depends_on:
forgejo:
condition: service_healthy
user: root
environment:
FORGEJO_INSTANCE_URL: http://forgejo:3000
FORGEJO_RUNNER_REGISTRATION_TOKEN: ${FORGEJO_RUNNER_REGISTRATION_TOKEN}
FORGEJO_RUNNER_NAME: vps-runner
volumes:
- forgejo-runner-data:/data
- ./forgejo-runner/config.yml:/data/config.yml:ro
- ./forgejo-runner/entrypoint.sh:/entrypoint.sh:ro
- /var/run/docker.sock:/var/run/docker.sock
networks:
- forgejo-internal
- web
entrypoint: ["/bin/sh", "/entrypoint.sh"]
# ===========================================
# BLOG STATIC (Hugo) - richardnixon.dev (cutover-ready, no Traefik labels yet)
# ===========================================
blog-static:
image: nginx:alpine
container_name: blog-static
restart: unless-stopped
volumes:
- /root/richardnixon.dev-hugo/public:/usr/share/nginx/html:ro
- ./blog-static/nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- web
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 30s
timeout: 3s
retries: 3
# ===========================================
# UMAMI ANALYTICS
# ===========================================
@ -743,6 +786,8 @@ services:
FORGEJO__log__LEVEL: info
FORGEJO__openid__ENABLE_OPENID_SIGNIN: "false"
FORGEJO__openid__ENABLE_OPENID_SIGNUP: "false"
FORGEJO__actions__ENABLED: "true"
FORGEJO__actions__DEFAULT_ACTIONS_URL: "https://code.forgejo.org"
volumes:
- forgejo-data:/data
- /etc/timezone:/etc/timezone:ro
@ -816,3 +861,4 @@ volumes:
authentik-templates:
forgejo-data:
forgejo-db-data:
forgejo-runner-data:

View file

@ -0,0 +1,30 @@
log:
level: info
runner:
file: /data/.runner
capacity: 2
timeout: 30m
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
labels:
- "docker:docker://node:20-bookworm"
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
cache:
enabled: true
dir: /data/.cache
container:
network: "infrastructure_forgejo-internal"
privileged: false
options: ""
workdir_parent: ""
valid_volumes:
- "/root/richardnixon.dev-hugo/public"
docker_host: "unix:///var/run/docker.sock"
force_pull: false
host:
workdir_parent: ""

View file

@ -0,0 +1,15 @@
#!/bin/sh
set -e
if [ ! -f /data/.runner ]; then
echo "registering runner..."
forgejo-runner register \
--no-interactive \
--instance "${FORGEJO_INSTANCE_URL}" \
--token "${FORGEJO_RUNNER_REGISTRATION_TOKEN}" \
--name "${FORGEJO_RUNNER_NAME}" \
--labels "docker:docker://node:20-bookworm,ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
echo "registered."
fi
exec forgejo-runner daemon -c /data/config.yml

View file

@ -74,9 +74,9 @@ http:
# === PUBLIC ROUTERS (crowdsec only) ===
# Django API + admin + media + feed + sitemap
# Django admin/API only (narrowed after Hugo cutover; Hugo owns blog content)
platform-api:
rule: "(Host(`richardnixon.dev`) || Host(`www.richardnixon.dev`)) && (PathPrefix(`/api`) || PathPrefix(`/admin`) || PathPrefix(`/media`) || PathPrefix(`/feed`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/ckeditor5`) || PathPrefix(`/i18n`) || PathPrefix(`/en/`) || PathPrefix(`/pt-br/`) || PathPrefix(`/static`))"
rule: "(Host(`richardnixon.dev`) || Host(`www.richardnixon.dev`)) && (PathPrefix(`/api`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/media`))"
entryPoints:
- websecure
service: platform-api
@ -86,14 +86,15 @@ http:
tls:
certResolver: letsencrypt
# Next.js frontend (everything else)
platform-frontend:
# Hugo static blog (catch-all for richardnixon.dev / www.richardnixon.dev)
blog-static:
rule: "Host(`richardnixon.dev`) || Host(`www.richardnixon.dev`)"
entryPoints:
- websecure
service: platform-frontend
service: blog-static
middlewares:
- crowdsec-bouncer
priority: 100
tls:
certResolver: letsencrypt
@ -206,6 +207,11 @@ http:
servers:
- url: "http://platform-frontend:3000"
blog-static:
loadBalancer:
servers:
- url: "http://blog-static:80"
wordpress:
loadBalancer:
servers: