The lab runs a set of self-hosted services including:

For new users, see Lab services onboarding.


Architecture Overview

Two-machine setup connected via an encrypted WireGuard tunnel:

Internet → Hetzner VPS (167.233.140.224)
               │
               ├── SSLH (port 443) → SSH-over-443 → WireGuard → Backend:2222 (Forgejo SSH)
               │                  ↘ HTTPS → Nginx (port 8443)
               │                               │
               │                               ├── pw.bonhamlab.bio        → Vaultwarden
               │                               ├── documents.bonhamlab.bio → Paperless
               │                               └── code.bonhamlab.bio      → Forgejo
               │
               └── WireGuard (port 51820/udp) ──── Backend (10.100.0.2)
                                                        │
                                                        └── Docker Compose
                                                            ├── Vaultwarden    :8080
                                                            ├── Paperless      :8000
                                                            ├── Forgejo        :3000/:2222
                                                            ├── Forgejo runner (CI)
                                                            ├── Postgres       (internal)
                                                            └── Redis          (internal)

Key Design Decisions

  • The VPS is a dumb proxy — it holds no application data. All services run on the backend.
  • Services bind to 10.100.0.2 (the WireGuard interface) only — not reachable from the public internet directly.
  • SSLH multiplexes port 443: SSH handshakes go to Forgejo’s built-in SSH server via the reverse tunnel; TLS goes to Nginx.
  • The backend is portable — migrating to new hardware means copying /srv/docker and /etc/wireguard/wg0.conf. The VPS config requires zero changes.
  • This machine is currently dogen, but will eventually be its own dedicated system.

WireGuard Addressing

HostWireGuard IP
Hetzner VPS10.100.0.1
Backend10.100.0.2

Domain Structure

SubdomainServiceAuth
pw.bonhamlab.bioVaultwardenVaultwarden native (local accounts)
documents.bonhamlab.bioPaperless-ngxLocal accounts + 2FA (TOTP)
code.bonhamlab.bioForgejoLocal accounts + 2FA enforced instance-wide
bonhamlab.bioAstro websitePublic (static, no auth)
wiki.bonhamlab.bioQuartz wikiPublic (static, no auth)

Authentication

Each service manages its own authentication independently:

  • Vaultwarden: local accounts with master password + optional 2FA (TOTP). Admin panel at /admin requires the admin token stored in Vaultwarden.
  • Forgejo: local accounts with password + 2FA (TOTP, enforced for all users via GLOBAL_TWO_FACTOR_REQUIREMENT = all in app.ini). GitHub and GitLab OAuth2 also available for collaborators who prefer it.
  • Paperless: local accounts with password + 2FA (TOTP, required by policy but not enforced at the app level — enforced during onboarding).

There is no shared SSO layer. Credentials for each service are stored in Vaultwarden.


Service Management

Backend

All Docker Compose commands run from /srv/docker.

ActionCommand
Start all servicescd /srv/docker && docker compose up -d
Stop all servicescd /srv/docker && docker compose down
Restart a servicecd /srv/docker && docker compose restart <service>
Force recreate (picks up env changes)cd /srv/docker && docker compose down && docker compose up -d
View logscd /srv/docker && docker compose logs -f <service>
Check statuscd /srv/docker && docker compose ps
Update imagescd /srv/docker && docker compose pull && docker compose up -d

Service names: postgres, redis, vaultwarden, paperless, forgejo, forgejo-runner

ActionCommand
WireGuard statussudo wg show
Start WireGuardsudo systemctl start wg-quick@wg0
Stop WireGuardsudo systemctl stop wg-quick@wg0
Forgejo SSH tunnel statussudo systemctl status forgejo-tunnel.service
Restart Forgejo SSH tunnelsudo systemctl restart forgejo-tunnel.service
Tunnel logssudo journalctl -u forgejo-tunnel.service --no-pager -n 50

VPS

ActionCommand
WireGuard statussudo wg show
Nginx reload (no downtime)sudo systemctl reload nginx
Nginx restartsudo systemctl restart nginx
Nginx config testsudo nginx -t
Nginx error logssudo tail -50 /var/log/nginx/error.log
SSLH restartsudo systemctl restart sslh
Check port 443 listenersudo ss -tlnp | grep 443
Check tunnel portsudo ss -tlnp | grep 2222

Admin Operations

Reset a user’s Forgejo password (when locked out):

docker exec -it --user 1000 selfhosted-forgejo-1 forgejo admin user change-password --username <username> --password '<newpassword>'

Reset a user’s Paperless password:

docker exec -it selfhosted-paperless-1 python3 manage.py changepassword <username>

Reset Forgejo 2FA (if user loses their device): Admin panel → Site AdministrationUser Accounts → edit user → Reset two-factor authentication

Vaultwarden admin panel: https://pw.bonhamlab.bio/admin — requires the admin token stored in Vaultwarden.


Adding a New User

See Lab services onboarding for the full user-facing procedure.

Admin steps before sending the user anything:

  1. Invite their email in the Vaultwarden admin panel (/admin → Users → Invite)
  2. Create a Forgejo account: Site AdministrationUser AccountsCreate User; set a temporary password
  3. Create a Paperless account: https://documents.bonhamlab.bio/admin/auth/user/add/
  4. Share all temporary credentials via Vaultwarden Send (https://pw.bonhamlab.bio/#/send) — never via email or chat

Adding a New Service

  1. Add DNS A record → 167.233.140.224 in Porkbun

  2. On VPS: sudo certbot certonly --nginx -d newservice.bonhamlab.bio

  3. On VPS: create /etc/nginx/sites-available/newservice.bonhamlab.bio, symlink to sites-enabled

    Minimal template (no auth gate — each service handles its own auth):

    server {
        listen 8443 ssl;
        http2 on;
        server_name newservice.bonhamlab.bio;
     
        ssl_certificate     /etc/letsencrypt/live/newservice.bonhamlab.bio/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/newservice.bonhamlab.bio/privkey.pem;
        include /etc/nginx/snippets/ssl-params.conf;
     
        client_max_body_size 100M;
     
        location / {
            proxy_pass http://10.100.0.2:<PORT>;
            include /etc/nginx/snippets/proxy-params.conf;
        }
    }
  4. On VPS: sudo nginx -t && sudo systemctl reload nginx

  5. On backend: add service block to /srv/docker/compose.yml

  6. On backend: if Postgres needed, create DB manually (init script only runs on first Postgres boot):

    docker exec -it selfhosted-postgres-1 psql -U postgres -c "CREATE USER newservice WITH PASSWORD 'password';"
    docker exec -it selfhosted-postgres-1 psql -U postgres -c "CREATE DATABASE newservice OWNER newservice;"

    Also add to /srv/docker/postgres/initdb/01-create-dbs.sh for future fresh installs.

  7. On backend: create data directory with correct ownership for the container’s uid:

    sudo mkdir -p /srv/docker/newservice/data
    sudo chown -R <uid>:<gid> /srv/docker/newservice/data
  8. On backend: cd /srv/docker && docker compose up -d newservice

  9. Verify binding: sudo ss -tlnp | grep <PORT> — should show 10.100.0.2:<PORT>


Static Sites

Two static sites are hosted directly by Nginx on the VPS (no backend container needed):

SitePath on VPSRepoBuild trigger
bonhamlab.bio/var/www/bonhamlab.bioWeb/bonhamlab.bioPush to main → Forgejo Actions → rsync
wiki.bonhamlab.bio/var/www/wiki.bonhamlab.bioInternal/ObsidianVaultPush to main → Forgejo Actions → clone Web/wiki-backend → build Quartz → rsync

Deploy SSH key is stored at /srv/docker/forgejo-runner/deploy_key on the backend (used by the runner to rsync to the VPS). The corresponding public key is in /home/kevin/.ssh/authorized_keys on the VPS, restricted via rrsync -wo /var/www/.


Files & Paths

Hetzner VPS

PathPurpose
/etc/wireguard/wg0.confWireGuard interface config
/etc/wireguard/private.keyWireGuard private key (root, 600)
/etc/wireguard/public.keyWireGuard public key
/etc/nginx/sites-available/pw.bonhamlab.bioVaultwarden Nginx vhost
/etc/nginx/sites-available/documents.bonhamlab.bioPaperless Nginx vhost
/etc/nginx/sites-available/code.bonhamlab.bioForgejo Nginx vhost
/etc/nginx/sites-available/bonhamlab.bioMain website Nginx vhost
/etc/nginx/sites-available/wiki.bonhamlab.bioWiki Nginx vhost
/etc/nginx/snippets/ssl-params.confShared TLS settings
/etc/nginx/snippets/proxy-params.confShared proxy headers
/etc/letsencrypt/live/pw.bonhamlab.bio/TLS cert (shared by code, auth subdomains)
/etc/letsencrypt/live/documents.bonhamlab.bio/TLS cert for documents subdomain
/etc/letsencrypt/live/bonhamlab.bio/TLS cert for apex + www
/etc/letsencrypt/live/wiki.bonhamlab.bio/TLS cert for wiki
/etc/default/sslhSSLH daemon config
/var/www/bonhamlab.bio/Built Astro site files
/var/www/wiki.bonhamlab.bio/Built Quartz wiki files

Backend (OpenSUSE Tumbleweed)

PathPurpose
/etc/wireguard/wg0.confWireGuard interface config
/etc/wireguard/private.keyWireGuard private key (root, 600)
/etc/wireguard/public.keyWireGuard public key
/etc/wireguard/tunnel_keySSH private key for Forgejo reverse tunnel (root, 600)
/etc/wireguard/tunnel_key.pubSSH public key for Forgejo reverse tunnel
/etc/systemd/system/selfhosted.serviceDocker Compose systemd unit
/etc/systemd/system/forgejo-tunnel.serviceForgejo SSH reverse tunnel systemd unit
/srv/docker/compose.ymlDocker Compose stack definition
/srv/docker/.envSecrets and environment variables (selfhosted:docker, 640)
/srv/docker/postgres/data/Postgres data volume (root, 700)
/srv/docker/postgres/initdb/01-create-dbs.shOne-time DB/user init script (runs on fresh Postgres only)
/srv/docker/redis/data/Redis data volume
/srv/docker/vaultwarden/data/Vaultwarden data volume
/srv/docker/paperless/data/Paperless data volume
/srv/docker/paperless/media/Paperless media volume
/srv/docker/paperless/consume/Paperless document ingestion drop folder (777)
/srv/docker/forgejo/data/Forgejo data volume
/srv/docker/forgejo/data/gitea/conf/app.iniForgejo runtime config (managed by container; env vars take precedence)
/srv/docker/forgejo-runner/config.ymlForgejo Actions runner connection config
/srv/docker/forgejo-runner/data/Forgejo runner working data
/srv/docker/forgejo-runner/deploy_keySSH private key for rsync deployment to VPS (root, 600)

Secrets & Keys

WireGuard Keys

Generated on each machine independently:

sudo sh -c 'umask 077; wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key'
  • VPS private key → PrivateKey in /etc/wireguard/wg0.conf [Interface]
  • VPS public key → PublicKey in backend /etc/wireguard/wg0.conf [Peer]
  • Backend private key → PrivateKey in backend /etc/wireguard/wg0.conf [Interface]
  • Backend public key → PublicKey in VPS /etc/wireguard/wg0.conf [Peer]

Forgejo Tunnel SSH Key

Generated on the backend:

sudo ssh-keygen -t ed25519 -f /etc/wireguard/tunnel_key -N "" -C "forgejo-tunnel"

Private key stays at /etc/wireguard/tunnel_key. Public key goes into /home/kevin/.ssh/authorized_keys on the VPS, restricted to port-forwarding only:

command="echo 'no shell'",no-pty,no-agent-forwarding,permitopen="localhost:2222" ssh-ed25519 ...

Deploy SSH Key

Generated on the backend for static site deployment:

sudo ssh-keygen -t ed25519 -f /srv/docker/forgejo-runner/deploy_key -N "" -C "site-deploy"

Public key in /home/kevin/.ssh/authorized_keys on VPS, restricted to rsync write-only under /var/www/:

command="rrsync -wo /var/www/",restrict ssh-ed25519 ...

Used in Forgejo Actions workflows via the DEPLOY_SSH_KEY secret (set per-repo, not org-wide).

Docker Compose Secrets (in /srv/docker/.env)

VariableGenerated withPurpose
POSTGRES_PASSWORDopenssl rand -base64 32Postgres superuser password
VAULTWARDEN_DB_PASSopenssl rand -base64 32Vaultwarden DB user password
PAPERLESS_DB_PASSopenssl rand -base64 32Paperless DB user password
FORGEJO_DB_PASSopenssl rand -base64 32Forgejo DB user password
PAPERLESS_SECRET_KEYopenssl rand -base64 48Paperless Django secret key
VAULTWARDEN_ADMIN_TOKENdocker run --rm -it vaultwarden/server /vaultwarden hash --profile owaspVaultwarden admin panel token (argon2 hash)

The raw Vaultwarden admin token (plaintext, pre-hash) is stored in Vaultwarden itself.


Migration to New Hardware

The VPS requires zero changes. On the new backend machine:

  1. Install Debian/OpenSUSE, Docker, WireGuard tools
  2. rsync -aAX /srv/docker/ newhost:/srv/docker/
  3. Copy /etc/wireguard/wg0.conf — same private key, same IP 10.100.0.2
  4. Copy /etc/systemd/system/selfhosted.service and forgejo-tunnel.service
  5. Copy /etc/wireguard/tunnel_key and /etc/wireguard/tunnel_key.pub
  6. sudo systemctl enable --now wg-quick@wg0
  7. sudo systemctl enable --now selfhosted.service
  8. sudo systemctl enable --now forgejo-tunnel.service
  9. Verify: sudo wg show and cd /srv/docker && docker compose ps