DEV Community

Deendayal Sundaria
Deendayal Sundaria

Posted on

I Consolidated My Entire Developer Homelab onto One Machine — Here's the Full Stack

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) │   │
                    │   └───────────────────────────┘   │
                    └───────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then edit /etc/systemd/logind.conf:

HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
IdleAction=ignore
Enter fullscreen mode Exit fullscreen mode
sudo systemctl restart systemd-logind
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The WEB_URL note 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two things here that are not obvious from the documentation:

  1. 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.
  2. 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      # ✅
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_KEY as 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)