<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Deendayal Sundaria</title>
    <description>The latest articles on DEV Community by Deendayal Sundaria (@ddsundaria).</description>
    <link>https://dev.to/ddsundaria</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F370169%2Fda528a15-3530-4a87-ba5e-cc502d5aa745.jpg</url>
      <title>DEV Community: Deendayal Sundaria</title>
      <link>https://dev.to/ddsundaria</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ddsundaria"/>
    <language>en</language>
    <item>
      <title>I Consolidated My Entire Developer Homelab onto One Machine — Here's the Full Stack</title>
      <dc:creator>Deendayal Sundaria</dc:creator>
      <pubDate>Fri, 05 Jun 2026 06:56:36 +0000</pubDate>
      <link>https://dev.to/ddsundaria/i-consolidated-my-entire-developer-homelab-onto-one-machine-heres-the-full-stack-496m</link>
      <guid>https://dev.to/ddsundaria/i-consolidated-my-entire-developer-homelab-onto-one-machine-heres-the-full-stack-496m</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm Running
&lt;/h2&gt;

&lt;p&gt;Eight services, 26 containers, one machine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Portainer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Docker management UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Uptime Kuma&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Service monitoring (7 monitors)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NocoDB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Self-hosted Airtable — CRM &amp;amp; leads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;n8n&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Workflow automation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Open WebUI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Local AI chat interface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ollama&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Local LLM inference (GPU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AFF!NE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Collaborative docs &amp;amp; whiteboards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plane&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Project management (roadmaps, sprints)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Duplicati&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Encrypted daily backups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zero Trust secure access — no open router ports&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All external-facing services sit behind &lt;strong&gt;Cloudflare Zero Trust&lt;/strong&gt; with email OTP. No passwords to manage, no VPN clients — Cloudflare handles authentication at the edge.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌──────────────────────────────────┐
                    │      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) │   │
                    │   └───────────────────────────┘   │
                    └───────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything runs on a shared Docker bridge network (&lt;code&gt;homelab-net&lt;/code&gt;). The &lt;code&gt;cloudflared&lt;/code&gt; container maintains an outbound-only encrypted tunnel — no inbound ports open on the router at all.&lt;/p&gt;

&lt;p&gt;Ollama runs in Docker with NVIDIA GPU passthrough. The AI model inference happens on the GPU, leaving CPU headroom for all other services.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu 24.04 LTS&lt;/li&gt;
&lt;li&gt;Docker Engine + Compose v2&lt;/li&gt;
&lt;li&gt;NVIDIA GPU with driver 535+&lt;/li&gt;
&lt;li&gt;NVIDIA Container Toolkit&lt;/li&gt;
&lt;li&gt;Cloudflare account (free tier is fine)&lt;/li&gt;
&lt;li&gt;A domain managed on Cloudflare DNS&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: NVIDIA Container Toolkit
&lt;/h2&gt;

&lt;p&gt;If you're skipping GPU passthrough, skip this. But if you have an NVIDIA GPU sitting idle, you may as well use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add NVIDIA repository&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://nvidia.github.io/libnvidia-container/gpgkey | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g'&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/nvidia-container-toolkit.list

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nvidia-container-toolkit

&lt;span class="c"&gt;# Configure Docker to use the GPU runtime&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nvidia-ctk runtime configure &lt;span class="nt"&gt;--runtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart docker

&lt;span class="c"&gt;# Test — you should see your GPU listed&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--gpus&lt;/span&gt; all ubuntu nvidia-smi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: Disable Sleep
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then edit &lt;code&gt;/etc/systemd/logind.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;HandleLidSwitch&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;
&lt;span class="py"&gt;HandleLidSwitchExternalPower&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;
&lt;span class="py"&gt;HandleLidSwitchDocked&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;
&lt;span class="py"&gt;IdleAction&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-logind
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: The .env File
&lt;/h2&gt;

&lt;p&gt;All secrets and configuration live in &lt;code&gt;~/homelab/.env&lt;/code&gt;. Generate random strings with &lt;code&gt;openssl rand -hex 32&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The &lt;code&gt;WEB_URL&lt;/code&gt; note is not optional.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: docker-compose.yml
&lt;/h2&gt;

&lt;p&gt;The full compose file is long, so I'll highlight the interesting parts. The complete file is linked at the end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ollama with GPU
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ollama&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama/ollama:latest&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;11434:11434'&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ollama-data:/root/.ollama&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;reservations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia&lt;/span&gt;
            &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
            &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;gpu&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homelab-net&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;deploy.resources.reservations.devices&lt;/code&gt; block is the key. Docker passes GPU access to the container via the NVIDIA Container Toolkit you installed in Step 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open WebUI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;open-webui&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/open-webui/open-webui:main&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3000:8080'&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OLLAMA_BASE_URL=${OLLAMA_BASE_URL}&lt;/span&gt;   &lt;span class="c1"&gt;# http://ollama:11434&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY}&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;open-webui-data:/app/backend/data&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ollama&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homelab-net&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OLLAMA_BASE_URL&lt;/code&gt; uses the Docker service name &lt;code&gt;ollama&lt;/code&gt;, not &lt;code&gt;localhost&lt;/code&gt;. This is a common mistake — containers can't reach each other via localhost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Duplicati (with the fix)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;duplicati&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lscr.io/linuxserver/duplicati:latest&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0:0'&lt;/span&gt;                        &lt;span class="c1"&gt;# ← Must run as root&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8200:8200'&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PUID=0&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PGID=0&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=Europe/Amsterdam&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SETTINGS_ENCRYPTION_KEY=${DUPLICATI_SETTINGS_KEY}&lt;/span&gt;  &lt;span class="c1"&gt;# ← Required in v2.3+&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;duplicati-config:/config&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/lib/docker/volumes:/source:ro&lt;/span&gt;   &lt;span class="c1"&gt;# read-only access to Docker volumes&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./backups:/backups&lt;/span&gt;                   &lt;span class="c1"&gt;# local backup destination&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homelab-net&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things here that are not obvious from the documentation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;user: '0:0'&lt;/code&gt; — Duplicati needs root to read Docker volume data in &lt;code&gt;/var/lib/docker/volumes&lt;/code&gt;. Running as a non-root user (the LinuxServer default) silently fails to read any volume.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SETTINGS_ENCRYPTION_KEY&lt;/code&gt; — Duplicati v2.3 will not start without this. The container just loops and restarts.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 5: Plane — The Full Gotcha
&lt;/h2&gt;

&lt;p&gt;Plane is the most complex service in the stack. Here's what you need to know before you start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use &lt;code&gt;:stable&lt;/code&gt;, not &lt;code&gt;:latest&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# These images had a broken build on :latest as of mid-2026&lt;/span&gt;
&lt;span class="s"&gt;makeplane/plane-frontend:stable&lt;/span&gt;   &lt;span class="c1"&gt;# ✅&lt;/span&gt;
&lt;span class="s"&gt;makeplane/plane-backend:stable&lt;/span&gt;    &lt;span class="c1"&gt;# ✅&lt;/span&gt;
&lt;span class="s"&gt;makeplane/plane-proxy:stable&lt;/span&gt;      &lt;span class="c1"&gt;# ✅&lt;/span&gt;
&lt;span class="s"&gt;makeplane/plane-space:stable&lt;/span&gt;      &lt;span class="c1"&gt;# ✅&lt;/span&gt;
&lt;span class="s"&gt;makeplane/plane-admin:stable&lt;/span&gt;      &lt;span class="c1"&gt;# ✅&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Extra containers required by &lt;code&gt;:stable&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The stable build needs containers that aren't in most community examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# One-shot migration container&lt;/span&gt;
&lt;span class="na"&gt;plane-migrator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;makeplane/plane-backend:stable&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;python'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;manage.py'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;migrate'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="c1"&gt;# ... depends on plane-db, plane-redis&lt;/span&gt;

&lt;span class="c1"&gt;# Message broker for workers&lt;/span&gt;
&lt;span class="na"&gt;plane-rabbitmq&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rabbitmq:3.12-alpine&lt;/span&gt;

&lt;span class="c1"&gt;# Additional frontends&lt;/span&gt;
&lt;span class="na"&gt;plane-space&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;makeplane/plane-space:stable&lt;/span&gt;

&lt;span class="na"&gt;plane-admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;makeplane/plane-admin:stable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The URL environment variables
&lt;/h3&gt;

&lt;p&gt;Every Plane backend container (&lt;code&gt;plane-api&lt;/code&gt;, &lt;code&gt;plane-worker&lt;/code&gt;, &lt;code&gt;plane-beat&lt;/code&gt;) needs &lt;strong&gt;all five&lt;/strong&gt; of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WEB_URL=${WEB_URL}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;APP_BASE_URL=${WEB_URL}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ADMIN_BASE_URL=${WEB_URL}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SPACE_BASE_URL=${WEB_URL}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CSRF_TRUSTED_ORIGINS=${WEB_URL}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GUNICORN_WORKERS=2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Plane frontend container (&lt;code&gt;plane-web&lt;/code&gt;, &lt;code&gt;plane-space&lt;/code&gt;, &lt;code&gt;plane-admin&lt;/code&gt;) needs these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NEXT_PUBLIC_API_BASE_URL=${WEB_URL}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NEXT_PUBLIC_WEB_BASE_URL=${WEB_URL}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NEXT_PUBLIC_SPACE_BASE_URL=${WEB_URL}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NEXT_PUBLIC_ADMIN_BASE_URL=${WEB_URL}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; Plane's &lt;code&gt;base_host()&lt;/code&gt; function constructs URLs dynamically from these variables. If any are missing, it returns &lt;code&gt;None&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Network aliases
&lt;/h3&gt;

&lt;p&gt;Plane's proxy container routes traffic internally using fixed hostnames (&lt;code&gt;web&lt;/code&gt;, &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;space&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;). You need to set these as network aliases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;plane-web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;homelab-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;aliases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;plane-api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;homelab-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;aliases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;plane-space&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;homelab-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;aliases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;space&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;plane-admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;homelab-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;aliases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;admin&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 6: AFF!NE Migration Container
&lt;/h2&gt;

&lt;p&gt;AFF!NE also needs a one-shot migration container, and the image name changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;affine-migration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/toeverything/affine:stable&lt;/span&gt;    &lt;span class="c1"&gt;# NOT affine-graphql&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dist/data-migrations/run.js'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL=postgresql://affine:${AFFINE_DB_PASSWORD}@affine-db/affine&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REDIS_SERVER_HOST=affine-redis&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;affine-db&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;affine-redis&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homelab-net&lt;/span&gt;

&lt;span class="na"&gt;affine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/toeverything/affine:stable&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;affine-db&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;affine-redis&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;affine-migration&lt;/span&gt;     &lt;span class="c1"&gt;# ← wait for migration to complete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 7: Cloudflare Zero Trust
&lt;/h2&gt;

&lt;p&gt;This is the part that makes the whole setup secure and accessible without a VPN.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tunnel setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The cloudflared container handles the tunnel&lt;/span&gt;
&lt;span class="c"&gt;# You just need the token from the Cloudflare dashboard&lt;/span&gt;
cloudflared:
  image: cloudflare/cloudflared:latest
  &lt;span class="nb"&gt;command&lt;/span&gt;: tunnel &lt;span class="nt"&gt;--no-autoupdate&lt;/span&gt; run
  environment:
    - &lt;span class="nv"&gt;TUNNEL_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLOUDFLARE_TUNNEL_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Cloudflare dashboard: &lt;strong&gt;Networks → Tunnels → Create tunnel → Cloudflared&lt;/strong&gt;. Copy the token into your &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Public hostnames
&lt;/h3&gt;

&lt;p&gt;Add one entry per service in the tunnel settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subdomain&lt;/th&gt;
&lt;th&gt;Internal Service&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;portainer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://portainer:9000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uptime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://uptime-kuma:3001&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;noco&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://nocodb:8080&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;n8n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://n8n:5678&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://open-webui:8080&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;affine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://affine:3010&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;plane&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://plane-proxy:80&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Duplicati (&lt;code&gt;8200&lt;/code&gt;) is &lt;strong&gt;not&lt;/strong&gt; in this list. Never expose it — it has no authentication layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access policy
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Zero Trust → Access → Applications → Add application → Self-hosted&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain: &lt;code&gt;*.yourdomain.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Policy: Emails → add your email address(es)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Starting the Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/homelab

&lt;span class="c"&gt;# Pull all images first (saves time on first start)&lt;/span&gt;
docker compose pull

&lt;span class="c"&gt;# Start everything&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Watch logs&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt;

&lt;span class="c"&gt;# Verify all containers&lt;/span&gt;
docker compose ps

&lt;span class="c"&gt;# Pull a model into Ollama&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;ollama ollama pull gemma2:2b

&lt;span class="c"&gt;# Verify GPU usage&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;ollama nvidia-smi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Backup Setup
&lt;/h2&gt;

&lt;p&gt;Duplicati runs daily at 03:00 and backs up all Docker volumes to &lt;code&gt;~/homelab/backups&lt;/code&gt;. Configure the job via the web UI at &lt;code&gt;http://your-machine-ip:8200&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source&lt;/strong&gt;: &lt;code&gt;/source&lt;/code&gt; (the read-only Docker volumes mount)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destination&lt;/strong&gt;: &lt;code&gt;/backups&lt;/code&gt; (local folder)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule&lt;/strong&gt;: Daily 03:00&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encryption&lt;/strong&gt;: AES-256 — use the value of &lt;code&gt;DUPLICATI_SETTINGS_KEY&lt;/code&gt; as the passphrase&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention&lt;/strong&gt;: 7 most recent versions&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Store the passphrase in a password manager. If you lose it, the encrypted backup files are unrecoverable.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Troubleshooting Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plane returns HTTP 500&lt;/td&gt;
&lt;td&gt;Missing URL env vars&lt;/td&gt;
&lt;td&gt;Add all 5 &lt;code&gt;*_URL&lt;/code&gt; vars to api/worker/beat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plane CSRF error in logs&lt;/td&gt;
&lt;td&gt;Same as above&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;base_host()&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; — set URL vars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plane containers crash loop&lt;/td&gt;
&lt;td&gt;Using &lt;code&gt;:latest&lt;/code&gt; build&lt;/td&gt;
&lt;td&gt;Switch all images to &lt;code&gt;:stable&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicati container restarts&lt;/td&gt;
&lt;td&gt;Missing &lt;code&gt;SETTINGS_ENCRYPTION_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Add it to environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicati reports 0 files backed up&lt;/td&gt;
&lt;td&gt;Running as non-root&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;user: '0:0'&lt;/code&gt;, &lt;code&gt;PUID=0&lt;/code&gt;, &lt;code&gt;PGID=0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open WebUI can't connect to Ollama&lt;/td&gt;
&lt;td&gt;Wrong URL&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;http://ollama:11434&lt;/code&gt; not &lt;code&gt;localhost&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AFF!NE fails to start&lt;/td&gt;
&lt;td&gt;Migration not run&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;affine-migration&lt;/code&gt; one-shot container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU not used by Ollama&lt;/td&gt;
&lt;td&gt;Toolkit not configured&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;nvidia-ctk runtime configure --runtime=docker&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine suspends / containers drop&lt;/td&gt;
&lt;td&gt;Sleep not disabled&lt;/td&gt;
&lt;td&gt;Mask sleep targets, set &lt;code&gt;HandleLidSwitch=ignore&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Resource Usage
&lt;/h2&gt;

&lt;p&gt;Here's the rough memory footprint with everything running:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Approximate RAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plane (api + worker + beat + web + db + redis + rabbitmq)&lt;/td&gt;
&lt;td&gt;~2.0 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AFF!NE (app + postgres + redis)&lt;/td&gt;
&lt;td&gt;~1.0 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NocoDB&lt;/td&gt;
&lt;td&gt;~500 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n&lt;/td&gt;
&lt;td&gt;~500 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama (model loaded)&lt;/td&gt;
&lt;td&gt;~1.5–4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open WebUI&lt;/td&gt;
&lt;td&gt;~400 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure (Portainer, Uptime Kuma, Duplicati, cloudflared)&lt;/td&gt;
&lt;td&gt;~600 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~7–10 GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;16 GB RAM is the comfortable minimum. 32 GB gives you plenty of headroom for running larger models in Ollama.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migrating to a New Machine
&lt;/h2&gt;

&lt;p&gt;When it's time to move to better hardware, export all volumes from the current machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down

&lt;span class="k"&gt;for &lt;/span&gt;vol &lt;span class="k"&gt;in &lt;/span&gt;nocodb-data n8n-data affine-data affine-db plane-db open-webui-data ollama-data&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; homelab_&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;vol&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;:/data &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/exports:/exports &lt;span class="se"&gt;\&lt;/span&gt;
    ubuntu &lt;span class="nb"&gt;tar &lt;/span&gt;czf /exports/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;vol&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Exported: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;vol&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transfer the &lt;code&gt;exports/&lt;/code&gt; directory to the new machine, then import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;vol &lt;span class="k"&gt;in &lt;/span&gt;nocodb-data n8n-data affine-data affine-db plane-db open-webui-data ollama-data&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;docker volume create homelab_&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;vol&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; homelab_&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;vol&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;:/data &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/exports:/exports &lt;span class="se"&gt;\&lt;/span&gt;
    ubuntu bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'cd /data &amp;amp;&amp;amp; tar xzf /exports/'&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;vol&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;'.tar.gz'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy your &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;docker-compose.yml&lt;/code&gt;, update the static IP if it changed, and &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Backblaze B2 as a second backup destination.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watchtower for automated image updates.&lt;/strong&gt; Right now I update images manually with &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;. Watchtower can automate this, though I prefer manual control for Plane specifically given the &lt;code&gt;:latest&lt;/code&gt; breakage above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Portainer secrets management.&lt;/strong&gt; Currently all secrets are in the &lt;code&gt;.env&lt;/code&gt; file. Portainer supports Docker Swarm secrets for better isolation — worth exploring if the setup grows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;The biggest time sink wasn't the setup itself — it was the Plane &lt;code&gt;:stable&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tags: #docker #selfhosted #homelab #cloudflare #ollama #devops #linux&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>cloudflarezerotrust</category>
      <category>ollamagpu</category>
      <category>selfhostedproductivitystack</category>
    </item>
  </channel>
</rss>
