Overview
Homelab on two Raspberry Pis:
- rpi-nginx (192.168.68.76): Nginx Proxy Manager, Cloudflared tunnel.
- rpi-400 (192.168.68.56): Prometheus, Grafana, n8n, Dozzle, Pi-hole.
<REDACTED> placeholders with your own values.DNS
Public zone: Cloudflare (perrylabs.cc). Public apps published via Cloudflare Tunnel.
LAN: Pi-hole provides *.home.perrylabs.cc A-records pointing to NPM (192.168.68.76).
Nginx Proxy Manager
Host: 192.168.68.76 Compose: ~/nginxproxymanager/compose.yaml
services:
npm:
image: jc21/nginx-proxy-manager:latest
container_name: npm
restart: unless-stopped
ports:
- "80:80"
- "81:81"
- "443:443"
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks: [edge]
networks:
edge:
external: true
For each proxy host: enable Block common exploits, Websockets (if needed), HTTP/2, cert *.home.perrylabs.cc, and Force SSL.
Cloudflared Tunnel
Host: 192.168.68.76 Compose: ~/cloudflared/compose.yaml
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=<REDACTED_TUNNEL_TOKEN>
networks: [edge]
networks:
edge:
external: true
Public hostnames in the tunnel point to origin https://npm:443, with No TLS Verify ON and both Host header and SNI set to the public hostname.
Service map
| Service | LAN | Public | NPM → Upstream | Notes |
|---|---|---|---|---|
| Grafana | https://grafana.home.perrylabs.cc | https://grafana.perrylabs.cc | 192.168.68.56:3000 | HTTP/2, WS, Force SSL |
| Portainer | https://portainer.home.perrylabs.cc | https://portainer.perrylabs.cc | 192.168.68.76:9000 | WS |
| n8n | https://n8n.home.perrylabs.cc | https://n8n.perrylabs.cc | 192.168.68.56:5678 | Basic Auth in app |
| Pi-hole #1 | https://pihole1.home.perrylabs.cc | https://pihole1.perrylabs.cc | 192.168.68.76:8082 | Access protected |
| Pi-hole #2 | https://pihole2.home.perrylabs.cc | https://pihole2.perrylabs.cc | 192.168.68.56:8082 | Access protected |
| NPM UI | https://npm.home.perrylabs.cc | https://npm.perrylabs.cc | 192.168.68.76:81 | Access protected |
| Dozzle | https://dozzle.home.perrylabs.cc | https://dozzle.perrylabs.cc | 192.168.68.56:8083 | Optional NPM Basic |
Monitoring
Host: 192.168.68.56.
Prometheus config
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ['192.168.68.56:9090']
labels: { host: 'rpi-400' }
relabel_configs:
- source_labels: [host]
target_label: instance
- job_name: node
static_configs:
- targets: ['192.168.68.56:9100']
labels: { host: 'rpi-400' }
- targets: ['192.168.68.76:9100']
labels: { host: 'rpi-nginx' }
relabel_configs:
- source_labels: [host]
target_label: instance
- job_name: cadvisor
static_configs:
- targets: ['192.168.68.56:8085']
labels: { host: 'rpi-400' }
- targets: ['192.168.68.76:8080']
labels: { host: 'rpi-nginx' }
relabel_configs:
- source_labels: [host]
target_label: instance
Dashboards
- Node Exporter Full — ID 1860
- Docker cAdvisor — ID 13946
- Docker Overview — ID 19908
n8n
Host: 192.168.68.56 Compose: ~/containers/n8n/compose.yaml
Final configuration with named volume, Basic Auth, task runners, and settings file permission enforcement. Secrets are redacted below.
services:
n8n:
image: n8nio/n8n:latest
container_name: n8n
platform: linux/arm64
restart: unless-stopped
ports:
- "5678:5678"
environment:
TZ: Australia/Sydney
WEBHOOK_URL: https://n8n.perrylabs.cc
N8N_PROTOCOL: https
N8N_PORT: "5678"
N8N_BASIC_AUTH_ACTIVE: "true"
N8N_BASIC_AUTH_USER: admin
N8N_BASIC_AUTH_PASSWORD: "<REDACTED_PASSWORD>"
N8N_ENCRYPTION_KEY: "<REDACTED_ENCRYPTION_KEY>"
N8N_RUNNERS_ENABLED: "true"
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data: {}
Notes: Do not set N8N_EDITOR_BASE_URL or N8N_HOST so the UI works for both LAN and public hostnames. Use WEBHOOK_URL for external callbacks.
Enable memory cgroups on Raspberry Pi
1) Add kernel flags (single-line file)
[ -f /boot/firmware/cmdline.txt ] && CMD=/boot/firmware/cmdline.txt || CMD=/boot/cmdline.txt
echo "Using $CMD"
sudo cp "$CMD" "$CMD.bak.$(date +%F-%H%M)"
grep -q 'cgroup_enable=memory' "$CMD" || \
sudo sed -i 's/$/ cgroup_memory=1 cgroup_enable=memory cgroup_enable=cpuset/' "$CMD"
sudo reboot
2) Verify after reboot
cat /sys/fs/cgroup/cgroup.controllers | tr ' ' '\n' | grep -x memory && echo "memory controller present"
3) cAdvisor mount
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
Dozzle
Compose: ~/containers/dozzle/compose.yaml
services:
dozzle:
image: amir20/dozzle:latest
container_name: dozzle
restart: unless-stopped
ports: ["8083:8080"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DOZZLE_ADDR=0.0.0.0:8080
Pi-hole
Each Pi-hole maps 8082:80 (web) and persists /etc/pihole and /etc/dnsmasq.d. Add local DNS records for each *.home.perrylabs.cc host → 192.168.68.76.
Security
Access (per app)
Self-hosted apps protected by email OTP. Apply to Grafana, Portainer, n8n, Dozzle, NPM UI, Pi-holes.
WAF: block non-AU
(http.host eq "perrylabs.cc" or ends_with(http.host, ".perrylabs.cc"))
and not ip.geoip.country in {"AU"}
Ops
- Prometheus hot reload:
--web.enable-lifecyclethenPOST /-/reload. - Grafana data dir: UID/GID 472:472 if bind-mounted.
- Dozzle answers 405 to
HEAD—useGETto test.