I recently rebuilt my homelab from scratch. The goal was simple: one machine, everything containerised, zero exposed ports, GPU-accelerated local AI, and a fully automated backup setup. No cloud subscriptions for the tools I use every day.
This is the full technical breakdown — what I'm running, how it's wired together, and the hard-won fixes that cost me hours so you don't have to repeat them.
What I'm Running
Eight services, 26 containers, one machine:
| Service | Purpose |
|---|---|
| Portainer | Docker management UI |
| Uptime Kuma | Service monitoring (7 monitors) |
| NocoDB | Self-hosted Airtable — CRM & leads |
| n8n | Workflow automation |
| Open WebUI | Local AI chat interface |
| Ollama | Local LLM inference (GPU) |
| AFF!NE | Collaborative docs & whiteboards |
| Plane | Project management (roadmaps, sprints) |
| Duplicati | Encrypted daily backups |
| Cloudflare Tunnel | Zero Trust secure access — no open router ports |
All external-facing services sit behind Cloudflare Zero Trust with email OTP. No passwords to manage, no VPN clients — Cloudflare handles authentication at the edge.
Architecture
┌──────────────────────────────────┐
│ Cloudflare Edge (Zero Trust) │
│ *.yourdomain.com — email OTP │
└──────────────┬───────────────────┘
│ HTTPS
┌──────────────▼───────────────────┐
│ Ubuntu Machine │
│ │
│ cloudflared (outbound tunnel) │
│ │ │
│ ┌─────▼────────────────────┐ │
│ │ homelab-net (bridge) │ │
│ │ │ │
│ │ portainer uptime-kuma │ │
│ │ nocodb n8n │ │
│ │ open-webui affine │ │
│ │ plane-* duplicati │ │
│ │ ollama (GPU passthrough) │ │
│ └───────────────────────────┘ │
└───────────────────────────────────┘
Everything runs on a shared Docker bridge network (homelab-net). The cloudflared container maintains an outbound-only encrypted tunnel — no inbound ports open on the router at all.
Ollama runs in Docker with NVIDIA GPU passthrough. The AI model inference happens on the GPU, leaving CPU headroom for all other services.
Prerequisites
- Ubuntu 24.04 LTS
- Docker Engine + Compose v2
- NVIDIA GPU with driver 535+
- NVIDIA Container Toolkit
- Cloudflare account (free tier is fine)
- A domain managed on Cloudflare DNS
Step 1: NVIDIA Container Toolkit
If you're skipping GPU passthrough, skip this. But if you have an NVIDIA GPU sitting idle, you may as well use it.
# Add NVIDIA repository
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt update && sudo apt install -y nvidia-container-toolkit
# Configure Docker to use the GPU runtime
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# Test — you should see your GPU listed
docker run --rm --gpus all ubuntu nvidia-smi
Step 2: Disable Sleep
This machine needs to stay on 24/7. Ubuntu will suspend the machine on lid close or idle by default — both will kill your containers silently.
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
Then edit /etc/systemd/logind.conf:
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
IdleAction=ignore
sudo systemctl restart systemd-logind
Step 3: The .env File
All secrets and configuration live in ~/homelab/.env. Generate random strings with openssl rand -hex 32.
# Cloudflare
CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token_here
# NocoDB
NC_JWT_SECRET=generate_32char_random_string
# n8n
N8N_HOST=n8n.yourdomain.com
N8N_USER=your_username
N8N_PASSWORD=your_strong_password
# AFF!NE
AFFINE_DB_PASSWORD=generate_32char_random_string
# Plane
PLANE_SECRET_KEY=generate_50char_random_string
# CRITICAL — all 5 must be set, same value, on all Plane backend services
# Missing even one causes base_host() = None → HTTP 500
WEB_URL=https://plane.yourdomain.com
# Open WebUI + Ollama
WEBUI_SECRET_KEY=generate_32char_random_string
OLLAMA_BASE_URL=http://ollama:11434
# Duplicati
DUPLICATI_SETTINGS_KEY=generate_32char_random_string
The
WEB_URLnote is not optional. This one variable (duplicated across 5 env var names in the compose file) is the single most common reason Plane returns 500 errors on self-hosted setups. More on this in the Gotchas section.
Step 4: docker-compose.yml
The full compose file is long, so I'll highlight the interesting parts. The complete file is linked at the end.
Ollama with GPU
ollama:
image: ollama/ollama:latest
restart: unless-stopped
ports:
- '11434:11434'
volumes:
- ollama-data:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
networks:
- homelab-net
The deploy.resources.reservations.devices block is the key. Docker passes GPU access to the container via the NVIDIA Container Toolkit you installed in Step 1.
Open WebUI
open-webui:
image: ghcr.io/open-webui/open-webui:main
restart: unless-stopped
ports:
- '3000:8080'
environment:
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL} # http://ollama:11434
- WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY}
volumes:
- open-webui-data:/app/backend/data
depends_on:
- ollama
networks:
- homelab-net
OLLAMA_BASE_URL uses the Docker service name ollama, not localhost. This is a common mistake — containers can't reach each other via localhost.
Duplicati (with the fix)
duplicati:
image: lscr.io/linuxserver/duplicati:latest
restart: unless-stopped
user: '0:0' # ← Must run as root
ports:
- '8200:8200'
environment:
- PUID=0
- PGID=0
- TZ=Europe/Amsterdam
- SETTINGS_ENCRYPTION_KEY=${DUPLICATI_SETTINGS_KEY} # ← Required in v2.3+
volumes:
- duplicati-config:/config
- /var/lib/docker/volumes:/source:ro # read-only access to Docker volumes
- ./backups:/backups # local backup destination
networks:
- homelab-net
Two things here that are not obvious from the documentation:
-
user: '0:0'— Duplicati needs root to read Docker volume data in/var/lib/docker/volumes. Running as a non-root user (the LinuxServer default) silently fails to read any volume. -
SETTINGS_ENCRYPTION_KEY— Duplicati v2.3 will not start without this. The container just loops and restarts.
Step 5: Plane — The Full Gotcha
Plane is the most complex service in the stack. Here's what you need to know before you start.
Use :stable, not :latest
# These images had a broken build on :latest as of mid-2026
makeplane/plane-frontend:stable # ✅
makeplane/plane-backend:stable # ✅
makeplane/plane-proxy:stable # ✅
makeplane/plane-space:stable # ✅
makeplane/plane-admin:stable # ✅
Extra containers required by :stable
The stable build needs containers that aren't in most community examples:
# One-shot migration container
plane-migrator:
image: makeplane/plane-backend:stable
command: ['python', 'manage.py', 'migrate']
# ... depends on plane-db, plane-redis
# Message broker for workers
plane-rabbitmq:
image: rabbitmq:3.12-alpine
# Additional frontends
plane-space:
image: makeplane/plane-space:stable
plane-admin:
image: makeplane/plane-admin:stable
The URL environment variables
Every Plane backend container (plane-api, plane-worker, plane-beat) needs all five of these:
environment:
- WEB_URL=${WEB_URL}
- APP_BASE_URL=${WEB_URL}
- ADMIN_BASE_URL=${WEB_URL}
- SPACE_BASE_URL=${WEB_URL}
- CSRF_TRUSTED_ORIGINS=${WEB_URL}
- GUNICORN_WORKERS=2
Every Plane frontend container (plane-web, plane-space, plane-admin) needs these:
environment:
- NEXT_PUBLIC_API_BASE_URL=${WEB_URL}
- NEXT_PUBLIC_WEB_BASE_URL=${WEB_URL}
- NEXT_PUBLIC_SPACE_BASE_URL=${WEB_URL}
- NEXT_PUBLIC_ADMIN_BASE_URL=${WEB_URL}
Why? Plane's base_host() function constructs URLs dynamically from these variables. If any are missing, it returns None, which breaks CSRF verification and every authenticated request returns HTTP 500. The error in the logs looks like a generic Django CSRF error — it doesn't tell you which env var is missing.
Network aliases
Plane's proxy container routes traffic internally using fixed hostnames (web, api, space, admin). You need to set these as network aliases:
plane-web:
networks:
homelab-net:
aliases: [web]
plane-api:
networks:
homelab-net:
aliases: [api]
plane-space:
networks:
homelab-net:
aliases: [space]
plane-admin:
networks:
homelab-net:
aliases: [admin]
Step 6: AFF!NE Migration Container
AFF!NE also needs a one-shot migration container, and the image name changed:
affine-migration:
image: ghcr.io/toeverything/affine:stable # NOT affine-graphql
command: ['node', 'dist/data-migrations/run.js']
environment:
- DATABASE_URL=postgresql://affine:${AFFINE_DB_PASSWORD}@affine-db/affine
- REDIS_SERVER_HOST=affine-redis
depends_on:
- affine-db
- affine-redis
networks:
- homelab-net
affine:
image: ghcr.io/toeverything/affine:stable
# ...
depends_on:
- affine-db
- affine-redis
- affine-migration # ← wait for migration to complete
Step 7: Cloudflare Zero Trust
This is the part that makes the whole setup secure and accessible without a VPN.
Tunnel setup
# The cloudflared container handles the tunnel
# You just need the token from the Cloudflare dashboard
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
In Cloudflare dashboard: Networks → Tunnels → Create tunnel → Cloudflared. Copy the token into your .env.
Public hostnames
Add one entry per service in the tunnel settings:
| Subdomain | Internal Service |
|---|---|
portainer |
http://portainer:9000 |
uptime |
http://uptime-kuma:3001 |
noco |
http://nocodb:8080 |
n8n |
http://n8n:5678 |
ai |
http://open-webui:8080 |
affine |
http://affine:3010 |
plane |
http://plane-proxy:80 |
Duplicati (8200) is not in this list. Never expose it — it has no authentication layer.
Access policy
Zero Trust → Access → Applications → Add application → Self-hosted
- Domain:
*.yourdomain.com - Policy: Emails → add your email address(es)
Every subdomain now requires email OTP verification before loading. No passwords to manage, no TOTP apps — Cloudflare sends a one-time code to your email.
Starting the Stack
cd ~/homelab
# Pull all images first (saves time on first start)
docker compose pull
# Start everything
docker compose up -d
# Watch logs
docker compose logs -f
# Verify all containers
docker compose ps
# Pull a model into Ollama
docker exec ollama ollama pull gemma2:2b
# Verify GPU usage
docker exec ollama nvidia-smi
Backup Setup
Duplicati runs daily at 03:00 and backs up all Docker volumes to ~/homelab/backups. Configure the job via the web UI at http://your-machine-ip:8200:
-
Source:
/source(the read-only Docker volumes mount) -
Destination:
/backups(local folder) - Schedule: Daily 03:00
-
Encryption: AES-256 — use the value of
DUPLICATI_SETTINGS_KEYas the passphrase - Retention: 7 most recent versions
Store the passphrase in a password manager. If you lose it, the encrypted backup files are unrecoverable.
Troubleshooting Reference
| Symptom | Cause | Fix |
|---|---|---|
| Plane returns HTTP 500 | Missing URL env vars | Add all 5 *_URL vars to api/worker/beat |
| Plane CSRF error in logs | Same as above |
base_host() returns None — set URL vars |
| Plane containers crash loop | Using :latest build |
Switch all images to :stable
|
| Duplicati container restarts | Missing SETTINGS_ENCRYPTION_KEY
|
Add it to environment |
| Duplicati reports 0 files backed up | Running as non-root | Set user: '0:0', PUID=0, PGID=0
|
| Open WebUI can't connect to Ollama | Wrong URL | Use http://ollama:11434 not localhost
|
| AFF!NE fails to start | Migration not run | Add affine-migration one-shot container |
| GPU not used by Ollama | Toolkit not configured | Run nvidia-ctk runtime configure --runtime=docker
|
| Machine suspends / containers drop | Sleep not disabled | Mask sleep targets, set HandleLidSwitch=ignore
|
Resource Usage
Here's the rough memory footprint with everything running:
| Component | Approximate RAM |
|---|---|
| Plane (api + worker + beat + web + db + redis + rabbitmq) | ~2.0 GB |
| AFF!NE (app + postgres + redis) | ~1.0 GB |
| NocoDB | ~500 MB |
| n8n | ~500 MB |
| Ollama (model loaded) | ~1.5–4 GB |
| Open WebUI | ~400 MB |
| Infrastructure (Portainer, Uptime Kuma, Duplicati, cloudflared) | ~600 MB |
| Total | ~7–10 GB |
16 GB RAM is the comfortable minimum. 32 GB gives you plenty of headroom for running larger models in Ollama.
Migrating to a New Machine
When it's time to move to better hardware, export all volumes from the current machine:
docker compose down
for vol in nocodb-data n8n-data affine-data affine-db plane-db open-webui-data ollama-data; do
docker run --rm \
-v homelab_${vol}:/data \
-v $(pwd)/exports:/exports \
ubuntu tar czf /exports/${vol}.tar.gz -C /data .
echo "Exported: ${vol}"
done
Transfer the exports/ directory to the new machine, then import:
for vol in nocodb-data n8n-data affine-data affine-db plane-db open-webui-data ollama-data; do
docker volume create homelab_${vol}
docker run --rm \
-v homelab_${vol}:/data \
-v $(pwd)/exports:/exports \
ubuntu bash -c 'cd /data && tar xzf /exports/'${vol}'.tar.gz'
done
Copy your .env and docker-compose.yml, update the static IP if it changed, and docker compose up -d.
What I'd Do Differently
Backblaze B2 as a second backup destination. The current setup backs up locally only. Adding B2 as an off-site destination in Duplicati takes about 10 minutes and costs pennies per month for the data volumes involved.
Watchtower for automated image updates. Right now I update images manually with docker compose pull && docker compose up -d. Watchtower can automate this, though I prefer manual control for Plane specifically given the :latest breakage above.
Portainer secrets management. Currently all secrets are in the .env file. Portainer supports Docker Swarm secrets for better isolation — worth exploring if the setup grows.
Closing Thoughts
The biggest time sink wasn't the setup itself — it was the Plane :stable migration and the missing URL environment variables. If you're hitting HTTP 500s on a fresh Plane install, the troubleshooting table above is the first place to look.
The Cloudflare Zero Trust approach is worth it. Zero open ports on the router, no VPN client to manage, and a proper authentication layer in front of every service. The free tier covers everything in this stack.
Tags: #docker #selfhosted #homelab #cloudflare #ollama #devops #linux
Top comments (0)