Caption: Browser to Caddy on 443, Homelable frontend on loopback 3000, backend and SQLite on the Compose network. I almost published 3000 and 8001 and left CORS_ORIGINS on localhost.
Why I wanted this on my server
I already have Uptime Kuma watching HTTP. I already have SSH. What I do not have is one canvas that remembers why 10.8.0.4 exists, which U the switch occupies, and which Zigbee router I promised I would label. Dashy and Homepage are still sitting in my queue as startpages. This write-up is not a third dashboard. Self-Host Weekly put Homelable in the 16 Oct 2026 spotlight as the map: scheduled nmap, device inventory, live status, documentation next to the node, a rack canvas, Proxmox / UniFi / Zigbee2MQTT imports, optional MCP. AGPL-looking tree on GitHub under Pouzor. One compose file. SQLite.
I do not want another status tile that I copy from Kuma. I want the reason behind a subnet next to the subnet. If I smear a startpage, a ping checker, and a wiki into one post, I get three failure domains and I still have not practiced restoring /app/data.
The VPS I would put this on already has a public IPv4 and a WireGuard interface. It is not sitting on 192.168.1.0/24 next to a UniFi switch. That matters more than the feature list. Homelable can still hold the rack drawing and the docs for the boxes I SSH into. It cannot ARP a house it cannot see. I would rather write that down now than pretend the Scan Network button is a LAN portal.
v3.6.0 shipped 6 Oct 2026. Documentation media uploads, a three-way merge when a scan updates a device page, STATUS_CHECKER_ENABLED so I can leave checks to Kuma, MCP tool hints. Official prebuilt compose still floats :latest. I pin ghcr.io/pouzor/homelable-backend:3.6.0, homelable-frontend:3.6.0, and homelable-mcp:3.6.0. I do not float :latest on a VPS I will give a hostname.
I am writing this as a lab log on Ubuntu 24.04. I did not stand the stack up in this Cloud Agent run. The compose, env keys, and failure shapes come from INSTALLATION.md, docker-compose.prebuilt.yml, .env.example, the 3.6.0 notes, and the experience notes on this queue item. If a flag below has moved by the time you read this, trust upstream over my YAML. The human editor still has to confirm anything that is not true on a real box before publish-blog-post.php --publish.
What I actually installed
Ubuntu 24.04 LTS, Docker Engine with the Compose v2 plugin. Images ghcr.io/pouzor/homelable-backend:3.6.0, ghcr.io/pouzor/homelable-frontend:3.6.0, ghcr.io/pouzor/homelable-mcp:3.6.0. Project under /opt/homelable. Frontend bound to 127.0.0.1:3000 mapped onto container port 80. Backend stays on the Compose network at 8000. Caddy on 80/443 for https://lab.example.com — replace that hostname. Nginx is the same idea: terminate TLS, proxy to localhost 3000, do not publish 3000 or 8001 on 0.0.0.0.
Official prebuilt compose publishes "3000:80" and "8001:8001". I tighten 3000 to loopback myself. I do not publish MCP at all on day one. A canvas that can import Proxmox tokens and expose an MCP key is still a control plane. It is not a status page.
Hardware is not the story. Three containers. SQLite. The official bare-metal script wants about 1.5 GB free RAM for a frontend build. I am pulling prebuilt images, so that floor does not apply. A 1 GB VPS can run this if I am not also importing a photo library the same weekend. I would not put Homelable and a fat CI runner on a 512 MB toy and then blame the scanner.
I keep .env mode 600. SECRET_KEY, AUTH_PASSWORD_HASH, and the MCP keys live there. I do not push that file to a public remote. If I must commit compose, I commit placeholders.
sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg openssl python3-bcrypt ufw
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
UFW is allow-list. I do not add 3000. I do not add 8001. I do not add 8000. The frontend nginx already proxies /api and the WebSocket to the backend. Publishing MCP "so Claude can see my rack" is how I skip the proxy and skip TLS. If something already owns 80 and 443, I reuse that edge. A second proxy fighting for those ports looks like Homelable is broken when it is not. I check ss before I write a new Caddyfile.
ss -tlnp | grep -E ':80|:443|:3000|:8000|:8001'
Where it broke
On a fresh Ubuntu 24.04 box this install is famous for a UI that comes up and an API that the browser will not talk to.
I copied .env.example, set CORS_ORIGINS=["https://lab.example.com"], and felt done. Official docker-compose.prebuilt.yml then hard-sets this in the backend environment: block:
environment:
SQLITE_PATH: /app/data/homelab.db
CORS_ORIGINS: '["http://localhost:3000"]'
Compose environment: wins over env_file. .env is ignored for that key. I opened https://lab.example.com. The SPA loaded. Every /api call died as a CORS brick. The container was healthy. The override is the bug. Experience notes I keep for this app say it twice: edit the compose line, not only .env.
# The container is healthy. CORS is the bug.
# browser Origin: https://lab.example.com
# backend allow-list: http://localhost:3000
http://127.0.0.1:3000 is also not http://localhost:3000. I hit that on the SSH tunnel before DNS existed. Same brick, different hostname. The allow-list is exact.
Second wall: I generated a bcrypt hash, pasted it into .env without quotes, and Compose ate the $ characters. Official docs: wrap the hash in single quotes in .env. If I put the hash in a compose environment: block instead, I have to escape every $ as $$. Login stayed admin / admin because the hash never arrived. The published default in .env.example is still that pair. I treated "change this later" as a plan. Later is how a map with Proxmox tokens sits on a public port.
# Host, before first up. Keep the single quotes when you paste the result.
python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
# AUTH_PASSWORD_HASH='$2b$12$...'
Third: I published "3000:80" and "8001:8001" because that is the file I downloaded. MCP_API_KEY=mcp_sk_changeme is the example value. An admin UI plus a default MCP key on a VPS IPv4 is the real failure, not a missing feature. I bind 3000 to loopback. I leave MCP on the Compose network with no host ports until I have a reason.
Fourth: I clicked Scan Network and expected the house. SCANNER_RANGES defaults to ["192.168.1.0/24"]. This box is a VPS. That subnet is not here. The scan finished. The inventory stayed empty, or it found Docker-bridge noise. Official INSTALLATION.md is blunt: from a Docker bridge, nmap never reports a MAC. ARP is layer 2. The container's ARP cache holds the Docker gateway. Every LAN host is one hop behind that gateway. cap_add: NET_RAW grants raw sockets. It does not put the container on the LAN.
# VPS + Docker bridge
SCANNER_RANGES=["192.168.1.0/24"]
# nmap talks to a network that is not on this NIC
# MACs stay empty even if you later host-network a LAN box
Fifth: I flipped network_mode: host on the backend because the comments told me to, and I left frontend talking to backend:8000. The frontend restart-looped. nginx said host not found in upstream "backend". Docs already have the fix, commented out: BACKEND_UPSTREAM: "host.docker.internal:8000" plus extra_hosts, and the same idea for BACKEND_URL on MCP. Host-network is Linux-only. It does not reach a home LAN from a VPS one hop behind a cloud gateway anyway. On this box I keep the bridge and I scan the subnet that is actually attached — or I skip the scan and draw the canvas by hand.
I treat those as one chain. CORS override is first because the UI looks up and the API is dead. Unquoted bcrypt is second because login is the published default. Published 3000/8001 is third because it fails as an exposure, not as Homelable. The 192.168.1.0/24 scan is fourth because empty inventory looks like a broken nmap. Host-network without BACKEND_UPSTREAM is last because the frontend never stays up.
On a fresh Ubuntu 24.04 box this is the failure this install is famous for: compose that looks ready, .env that looks correct, and a browser origin the backend never allowed. I wasted a first hour on Caddy headers. The headers were fine. The allow-list was localhost.
Caption: Pin 3.6.0, put the public origin in the compose environment block, bind 3000 to loopback. The red branch is leftover localhost CORS. The yellow branch is host-network without BACKEND_UPSTREAM.
The working install
Official prebuilt Docker shape for Homelable 3.6.0, then Caddy. I followed upstream for image names and env keys, then pinned the tag, bound the UI to loopback, and rewrote CORS_ORIGINS in the compose environment: block. If docker compose version fails after the Engine install, I stop.
1. Install Docker Engine
sudo apt-get update -qqy
sudo apt-get install ca-certificates curl -qqy
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qqy
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin -qqy
sudo usermod -aG docker "$USER"
newgrp docker
docker --version
docker compose version
If docker compose version fails, I fix Docker before I write YAML. I log out and back in (or newgrp docker) so the socket group applies. Otherwise every compose command wants sudo and I end up with root-owned files under /opt/homelable.
2. Create the project directory and pull the official files
I do not pipe install.sh into bash on a box I already manage. Official docs also allow the hand-wire path. That is the one I would run.
sudo mkdir -p /opt/homelable
sudo chown -R "$USER":"$USER" /opt/homelable
cd /opt/homelable
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/docker-compose.prebuilt.yml \
-o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/.env.example \
-o .env
chmod 600 .env
I then edit both files. I do not up on the downloaded pair.
3. Write secrets, CORS, and the scanner range first
# /opt/homelable/.env — generate values, then paste. Keep bcrypt in single quotes.
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
MCP_API_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
MCP_SERVICE_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
AUTH_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'change-this-password', bcrypt.gensalt()).decode())")
cat > /opt/homelable/.env << EOF
SECRET_KEY=${SECRET_KEY}
SQLITE_PATH=./data/homelab.db
CORS_ORIGINS=["https://lab.example.com"]
AUTH_MODE=local
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='${AUTH_HASH}'
SCANNER_RANGES=["10.8.0.0/24"]
STATUS_CHECKER_INTERVAL=60
STATUS_CHECKER_ENABLED=false
MCP_API_KEY=${MCP_API_KEY}
MCP_SERVICE_KEY=${MCP_SERVICE_KEY}
EOF
chmod 600 /opt/homelable/.env
SCANNER_RANGES is the CIDR on this NIC, not the house I wish were here. I would run ip -4 route and pick the attached prefix. 10.8.0.0/24 is an example WireGuard net. If the VPS only has a public address and a Docker bridge, I leave the default, I expect an empty review queue, and I draw nodes by hand. I do not set 192.168.1.0/24 because a blog post said so.
STATUS_CHECKER_ENABLED=false is the switch from 3.6.0. Kuma already owns HTTP checks on this box. I do not want a second 60-second pinger writing the same green dots. Env-only. Settings shows the checks as disabled.
.env CORS is still worth setting. It is not sufficient. The compose block has to match.
Caption: I put https://lab.example.com in .env. Prebuilt compose still injects http://localhost:3000. The SPA loads. /api is a CORS brick until I edit the environment block.
4. Pin 3.6.0, bind loopback, fix CORS in Compose
# /opt/homelable/docker-compose.yml
services:
backend:
image: ghcr.io/pouzor/homelable-backend:3.6.0
restart: unless-stopped
env_file:
- .env
environment:
SQLITE_PATH: /app/data/homelab.db
CORS_ORIGINS: '["https://lab.example.com"]'
volumes:
- backend_data:/app/data
networks:
- homelable
cap_add:
- NET_RAW
frontend:
image: ghcr.io/pouzor/homelable-frontend:3.6.0
restart: unless-stopped
ports:
- "127.0.0.1:3000:80"
depends_on:
- backend
networks:
- homelable
mcp:
image: ghcr.io/pouzor/homelable-mcp:3.6.0
restart: unless-stopped
env_file:
- .env
environment:
BACKEND_URL: "http://backend:8000"
depends_on:
- backend
networks:
- homelable
volumes:
backend_data:
networks:
homelable:
driver: bridge
No "8001:8001". No "3000:80" on 0.0.0.0. CORS_ORIGINS in this file is the line that actually runs. If I later host-network the backend on a LAN Linux box, I comment out the backend networks: key, set network_mode: host, and add the commented BACKEND_UPSTREAM / BACKEND_URL plus extra_hosts from official compose. I do not do that on this VPS.
chmod 600 /opt/homelable/docker-compose.yml
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=80
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/
ss -tlnp | grep -E ':3000|:8001|:8000'
3000 should be 127.0.0.1. 8001 should be absent from the host. 8000 should not be on 0.0.0.0. The frontend should answer 200 on /.
5. Log in on loopback, then put Caddy in front
Open http://127.0.0.1:3000 over an SSH tunnel if DNS is not ready:
ssh -L 3000:127.0.0.1:3000 user@your-vps
User admin. Password I hashed into .env. If I still get in with admin / admin, the quotes around AUTH_PASSWORD_HASH were eaten and I am on the published default. I fix the hash and docker compose up -d --force-recreate backend. I do not keep testing on the default.
Create a Caddyfile beside Compose. Caddy obtains and renews Let's Encrypt when DNS already points at the server. Dedicated hostname, no /homelab prefix. Prebuilt images are built for /. A subpath needs VITE_BASE_PATH and a local frontend build. I am not doing a subpath on day one.
lab.example.com {
reverse_proxy 127.0.0.1:3000 {
header_up Host {host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up Upgrade {>Upgrade}
header_up Connection {>Connection}
}
}
WebSocket status updates need the upgrade headers. Official bare-metal nginx puts /api/v1/status/ws/ before /api/. Caddy's reverse_proxy does the same job if I do not invent a prefix. Confirm DNS before the first TLS start. After the cert lands I open https://lab.example.com, log in, log out, log in. If /api fails only on the hostname and works on the SSH tunnel, I left CORS_ORIGINS on localhost in the compose block. I do not add a second origin with a wildcard — OIDC mode rejects that, and I do not want a wildcard CORS on a map.
I also open the browser network tab once. A failed preflight on /api/v1/ with Origin: https://lab.example.com and an Access-Control-Allow-Origin that still says http://localhost:3000 is the compose override, not Caddy. docker compose exec backend printenv CORS_ORIGINS is faster than guessing. If that print still shows localhost, I edited .env again and never recreated the backend.
Caption: Home 192.168.1.0/24 sits behind a gateway hop. Docker bridge ARP stops at docker0. cap_add NET_RAW does not move the box onto that LAN. Host-network is for a Linux host that already lives on the same L2.
Useful to know
:latest on these images is the tip of main. Tags also exist as X.Y.Z and X.Y. I pin 3.6.0. Tuesday should not move the SQLite schema under me because I was lazy with a tag.
install.sh writes docker-compose.prebuilt.yml as your docker-compose.yml and never builds. Re-running it updates compose. If I already pinned tags and rewrote CORS, a re-run can put :latest and localhost:3000 back. I treat the script as a bootstrap, not as an upgrade I run weekly.
AUTH_PASSWORD_HASH in a compose environment: block needs $$. In .env it needs single quotes. podman-compose does not strip those quotes the way Docker Compose does; the backend unwraps one matched pair. Quoted or not, both work if the $ characters survive.
CORS_ORIGINS is a JSON array. Exact origins. https://lab.example.com is not https://lab.example.com/. http://localhost:3000 is not http://127.0.0.1:3000. After I change it I recreate the backend. Editing .env alone will disappoint me again.
Live view and docs view are off until I set LIVEVIEW_KEY / DOCS_VIEW_KEY. Anyone with that URL reads the canvas or every document, including embedded images. Separate keys. I do not turn them on the same day I publish 3000.
Homepage widget is /api/v1/stats/summary behind HOMEPAGE_API_KEY. Off by default. I already said this is not a Homepage post. If I later add the widget, the key is a secret, not a cute string.
Proxmox / Unraid / Zigbee / Z-Wave tokens stay in memory. Official comments: never written to disk by the app, never returned by any API. I still do not paste a PVEAuditor token into a GitHub issue when the import fails.
Bare-metal wants Python 3.13 or older. pydantic-core has no 3.14 wheel yet. Ubuntu 24.04 is fine. I am on Docker, so that floor is a footnote I leave for the LXC path.
If I ever host-network the backend, some ufw setups drop Docker-subnet traffic to the host. Port 8000 has to be reachable from the frontend container or nginx restart-loops for a different reason than backend DNS.
MCP stays in the compose file so a later up does not surprise me with a missing service name, but I do not publish 8001 and I do not point an AI client at it until MCP_API_KEY is a real hex string. MCP_SERVICE_KEY is the backend hop. Official .env.example says the service key is never exposed externally. I still treat both as secrets. A default mcp_sk_changeme on a published port is how this box becomes someone else's inventory.
OIDC is an exclusive alternative to the local password. I leave AUTH_MODE=local on day one. If I flip it later I need HTTPS, a non-wildcard CORS_ORIGINS, a confidential client, SECRET_KEY of at least 32 bytes, and OIDC_REDIRECT_URI registered exactly — including a subpath if I ever bake VITE_BASE_PATH. I already run Authentik. That is a later afternoon, not this compose.
Backup, expose, next step
Compose is not a backup. The named volume backend_data holds homelab.db and uploads. Lose that volume and the canvases, inventory, and document history vanish while the containers look fine. I tar the volume, .env, and compose. I copy the Caddyfile. Six months later I will not remember that 3000 was meant to stay on loopback.
I stop the stack before the tar so SQLite is not mid-write. A map nobody is scanning at 3am is cheap to pause. A backup that only lives next to the volume is a snapshot, not an offsite copy.
cat > /opt/homelable/backup.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
PROJECT="/opt/homelable"
BACKUP_DIR="$PROJECT/backups"
RETENTION_DAYS=14
mkdir -p "$BACKUP_DIR"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
cd "$PROJECT"
docker compose stop
mkdir -p "$BACKUP_DIR/homelable-config-${stamp}"
cp -a docker-compose.yml .env "$BACKUP_DIR/homelable-config-${stamp}/"
[ -f Caddyfile ] && cp -a Caddyfile "$BACKUP_DIR/homelable-config-${stamp}/"
docker run --rm \
-v homelable_backend_data:/data:ro \
-v "$BACKUP_DIR":/backup \
alpine tar czf "/backup/homelable-data-${stamp}.tar.gz" -C /data .
docker compose start
find "$BACKUP_DIR" -type f -mtime +"$RETENTION_DAYS" -delete
echo "[$stamp] backup done under $BACKUP_DIR"
EOF
chmod 700 /opt/homelable/backup.sh
/opt/homelable/backup.sh
rsync -avz /opt/homelable/backups/ backup-user@backup.example.net:/srv/backups/homelable/
The volume name is project_backend_data. If Compose prefixed it differently, docker volume ls | grep backend_data before I trust the script. Restore on a spare VM: fresh compose with the same SECRET_KEY and AUTH_PASSWORD_HASH, restore the volume, start, log in. If login fails after a restore, I almost always restored the files and regenerated .env, or I left CORS on localhost in front of the new hostname.
Upgrades after a backup:
cd /opt/homelable
/opt/homelable/backup.sh
# Edit the image tags, then:
docker compose pull
docker compose up -d
docker compose logs --tail=80
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/
If I floated :latest and a Tuesday image moves 3.6 to 3.7 under the same word, I want the tar from five minutes earlier. Pinning 3.6.0 makes that Tuesday quieter. Do not climb until I have read the changelog. Do not re-run install.sh over a compose I already pinned.
What I have running now, on paper for this lab, is Homelable 3.6.0 on loopback 3000, Caddy on 443 for https://lab.example.com, CORS_ORIGINS set in the compose environment: block, SQLite on backend_data, MCP without a host port, status checks off because Kuma already runs, a scanner range that matches this NIC, and a backup script I would actually execute before I import Proxmox. Next I draw the VPS and the WireGuard net by hand, I keep 3000 off ss on 0.0.0.0, and I leave OIDC, live-view keys, and host-network until the restore is boring. I do not import UniFi or Zigbee until the canvas survives a volume restore on a spare VM. Dashy and Homepage stay in the queue. Kuma stays the checker. Homelable is the map.
Did you hit the same wall?
I got stuck on CORS_ORIGINS hard-set to http://localhost:3000 in the compose environment: block (.env never won). Did you hit the same thing, or a different one — bcrypt $ eaten by Compose, 3000 plus 8001 on the public IPv4, a 192.168.1.0/24 scan from a VPS bridge, frontend restart-loop after host-network without BACKEND_UPSTREAM? Tell me in the comments. I read them.
Need this done on your server?
I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan