Deployment Starting Point
This document is the "Master Blueprint" for the server. It details the physical directory structure, the host OS configuration, and the step-by-step "Zero-to-Hero" guide for rebuilding the entire infrastructure on a fresh OS installation.
Directory Tree
This structure reflects the live server state on /mnt/pool01.
/mnt/pool01/
├── dockerapps/ # (LV: lv_dockerapps)
│ ├── .env # Global secrets
│ ├── scripts/ # Automation scripts
│ ├── docs/ # Documentations
│ │
│ ├── gateway-stack/
│ │ ├── caddy/
│ │ ├── crowdsec/
│ │ ├── voidauth/
│ │ └── tailscale/
│ │
│ ├── media-stack/
│ │ ├── jellyfin/
│ │ ├── seerr/
│ │ └── vpn-arr-stack/
│ │ ├── gluetun/
│ │ ├── radarr/
│ │ └── ...
│ │
│ ├── mon-stack/
│ │ ├── arcane
│ │ ├── beszel
│ │ ├── dozzle
│ │ ├── homepage
│ │ └── wud
│ │ *socket-proxy is stateless - no folders needed
│ │
│ └── ops-stack/
│ ├── gotify/
│ ├── goaccess/
│ └── kopia/
│
├── media/ # (LV: lv_media) - The "Atomic" Zone
│ ├── downloads/ # Ingest Zone
│ │ ├── incomplete/ # Active downloads
│ │ ├── radarr/ # Completed Movies
│ │ └── ...
│ ├── movies/ # Library (Hardlinked)
│ └── shows/ # Library (Hardlinked)
│
└── games/ # (LV: lv_games)
Bind Mounts
We MUST manually create every folder referenced in a compose.yml file BEFORE starting the container.
If Docker auto-creates a missing folder (e.g., tailscale/state), it creates it as root:root. The container (running as PUID=1000) will crash with Permission Denied.
Deployment Guide (Zero-to-Hero)
1. Set Up Local Folders
To recreate this skeleton on a new machine (after mounting LVMs) and set the correct permissions:
# 1. Create Media Structure
sudo mkdir -p /mnt/pool01/media/{movies,shows,anime-movies,anime-shows}
sudo mkdir -p /mnt/pool01/media/downloads/{incomplete,radarr,sonarr,radarr-anime,sonarr-anime}
# 2. Set Media Permissions (Critical for Container Access)
sudo chown -R $USER:$USER /mnt/pool01/media
sudo chmod -R 775 /mnt/pool01/media
# 3. Create Docker Base
sudo mkdir -p /mnt/pool01/homelab/services/{gateway-stack,media-stack,ops-stack,mon-stack,scripts}
sudo chown -R $USER:$USER /mnt/pool01/homelab/services
# 4. Create Service Mount Points (Prevent Root Trap)
# Gateway Stack
mkdir -p /mnt/pool01/homelab/services/gateway-stack/caddy/{logs,data,config,maxmind}
mkdir -p /mnt/pool01/homelab/services/gateway-stack/crowdsec/{config,data}
mkdir -p /mnt/pool01/homelab/services/gateway-stack/tailscale/state
mkdir -p /mnt/pool01/homelab/services/gateway-stack/voidauth/{config,db}
# Monitoring Stack
mkdir -p /mnt/pool01/homelab/services/mon-stackk/beszel/{beszel_agent_data,data}
mkdir -p /mnt/pool01/homelab/services/mon-stack/arcane/arcane-data
mkdir -p /mnt/pool01/homelab/services/mon-stack/dozzle
mkdir -p /mnt/pool01/homelab/services/mon-stack/homepage/config/{icons,logs}
mkdir -p /mnt/pool01/homelab/services/mon-stack/wud
# Ops Stack
mkdir -p /mnt/pool01/homelab/services/ops-stack/gotify/data
mkdir -p /mnt/pool01/homelab/services/ops-stack/goaccess/{data,html}
mkdir -p /mnt/pool01/homelab/services/ops-stack/kopia/{cache,config,logs}
# Media Stack
mkdir -p /mnt/pool01/homelab/services/media-stack/jellyfin/{jellyfin-cache,jellyfin-config,seerr-config}
mkdir -p /mnt/pool01/homelab/services/media-stack/vpn-arr-stack/gluetun/{auth,config}
mkdir -p /mnt/pool01/homelab/services/media-stack/vpn-arr-stack/{bazarr,flaresolverr,jackett,profilarr,prowlarr,radarr,sonarr,transmission,qbittorrent}/config
#Speedtest for Gluetun/VPN (optional)
mkdir -p /mnt/pool01/homelab/services/media-stack/vpn-arr-stack/speedtest-tracker
2. Install Dependencies
sudo pacman -S docker docker-compose git firewalld
sudo systemctl enable --now docker firewalld
3. Configure Docker Daemon (IPv6 & DNS)
Goal: Enable IPv6, Fix DNS, Prevent Data Corruption, and Relocate Storage (instead of residing in typical /var/lib/docker, I prefer to have the docker data in my mounted drive/pool vs in OS storage)
Action: Create/Edit /etc/docker/daemon.json:
{
"ipv6": true,
"fixed-cidr-v6": "fd00:dead:beef:1::/64",
"experimental": true,
"ip6tables": true,
"dns": ["1.1.1.1", "8.8.8.8"],
"shutdown-timeout": 30,
"data-root": "/mnt/pool01/homelab/services/docker-data"
}
Expand to View: daemon.json config why
| Config Key | What it does | Why we need it? |
|---|---|---|
"ipv6": true |
Enables IPv6 networking support. | Mobile Access. Most 4G/5G networks in SG are IPv6-native. Without this, Android/iOS apps often fail to connect to the server when we are away from home. |
"fixed-cidr-v6" |
Assigns a specific IPv6 subnet to containers. | Routing. Docker needs to know which IPv6 addresses it is allowed to hand out to containers so they don't conflict with our router. |
"ip6tables": true |
Allows Docker to write IPv6 firewall rules. | Security. Without this, IPv6 traffic might bypass our firewall, exposing containers directly to the internet. |
"experimental": true |
Enables features not yet stable. | Compatibility. Required on some Linux kernels to make ip6tables work correctly with Docker. |
"dns": [...] |
Hardcodes Google/Cloudflare DNS. | Reliability. Prevents "Temporary Failure in Name Resolution" errors if the Host OS's local DNS resolver (systemd-resolved) gets stuck or acts weirdly. |
"shutdown-timeout": 30 |
(NEW) Waits 30s before killing a container. | Anti-Corruption. Default is 10s. Heavy apps (WUD, Arrs, Databases) often need >15s to save their state to disk on shutdown. This prevents "Exit Code 137" and database corruption. |
"data-root": "..." |
(NEW) Moves storage from /var/lib/docker. |
Space Management. By default, Docker stores all images/containers on our OS drive (Root Partition). We move this to /mnt/pool01/homelab/services (NVMe Pool) so we don't burden the OS limited space by filling up the root partition with docker images. |
Restart to Apply:
# 1. Stop Docker (Critical before moving data-root)
sudo systemctl stop docker
# 2. (Optional) If we have existing data in /var/lib/docker, move it now:
# sudo rsync -aP /var/lib/docker/ /mnt/pool01/homelab/services/docker-data/
# 3. Start Docker
sudo systemctl start docker
4. Create Custom Docker Network
Create the dockerapps-net bridge with isolated IPv6 subnets.
docker network create \
--driver=bridge \
--ipv6 \
--subnet=172.20.0.0/24 \
--gateway=172.20.0.1 \
--subnet=fd00:dead:beef:2::/64 \
--gateway=fd00:dead:beef:2::1 \
dockerapps-net
5. Configure Firewall (Software VLAN)
Goal: Block containers (172.20.x.x) from accessing the Home LAN (192.168.x.x).
# 1. Open Required Ports
sudo firewall-cmd --add-service=http --permanent
sudo firewall-cmd --add-service=https --permanent
sudo firewall-cmd --add-port=8096/tcp --permanent
# 2. Create the Chains FIRST
sudo firewall-cmd --permanent --direct --add-chain ipv4 filter DOCKER-USER
sudo firewall-cmd --permanent --direct --add-chain ipv6 filter DOCKER-USER
# 3. Reload to make sure the chains exist in memory
sudo firewall-cmd --reload
# 4. NOW add the Rules
# (IPv4 Block)
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter DOCKER-USER 0 -s 172.20.0.0/24 -d 192.168.0.0/24 -j DROP
# (IPv6 Block)
sudo firewall-cmd --permanent --direct --add-rule ipv6 filter DOCKER-USER 0 -s fd00:dead:beef:1::/64 -d fe80::/10 -j DROP
# 4. Reload
sudo firewall-cmd --reload
sudo systemctl restart docker
Note: I disabled ufw in replacement of firewalld
6. Identify Host IDs (For .env)
Run this to get the IDs needed for our .env files:
echo "PUID=$(id -u)"
echo "PGID=$(id -g)"
echo "DOCKER_GID=$(getent group docker | cut -d: -f3)"
Finding out Docker's GID
Reason is so that I can input it (example for mine is 958) in Homepage's compose file ie: PGID=958
7. Cloudflare DNS Setup
Create an A Record pointing to *.domain.com to our Public IP.
Create a Cloudflare API Token with Zone:DNS:Edit permissions for Caddy.
8. AirVPN Setup
This would be the perfect time to start setting up our VPN to work with Gluetun - https://github.com/qdm12/gluetun as part of our torrent downloads process. I chose AirVPN.
Populate Setup via Repo
Clone the Repo
Clone the stacks into the target structure. This pulls down all Compose files, scripts, and selected configs.
cd /mnt/pool01/homelab/services
git clone https://github.com/sheikhfarhan/homelab-server.git .
(Note: The . at the end clones directly into the current folder instead of making a subfolder)
Configure Secrets (The "Master" .env)
Instead of managing a dozen different .env files scattered across various service folders, I tend to use a single **Master .env** file at the root of the directory (/mnt/pool01/homelab/services/.env).
This acts as the single source of truth for all shared configurations (like user permissions) and sensitive credentials across every Docker Compose stack.Then I symlink it to any required .env file in any stacks/standalone folder alongside its own compose.yml file.
ln -s /mnt/pool01/homelab/services/.env /mnt/pool01/homelab/services/path-to-stack/or/service/.env
Create local configuration file
Expand to view the environment variables template
#### Generic ####
BACKUP_USER=<for_rclone_script>
PUID=1000
PGID=1000
TZ=Asia/Singapore
#### CADDY ####
#### CF DNS Zone API Token for domain.com ####
CLOUDFLARE_API_TOKEN=
#### Crowdsec Bouncer ####
CROWDSEC_API_KEY=
#### Root Domain ####
ROOT_DOMAIN=
#### For Maxmind GeoIP ####
MAXMIND_ACCOUNT_ID=
MAXMIND_LICENSE_KEY=
#### For VOIDAUTH ####
VOIDAUTH_STORAGE_KEY=
VOIDAUTH_DB_PASS=
VOIDAUTH_POSTGRES_PASS=
PASS_STRENGTH=2
APP_TITLE='Title_Goes_Here'
#### FOR JELLYFIN ####
#### CLOUDFLARED - CF TUNNEL IF USING THIS INSTEAD OF CADDY REVERSE PROXY ####
#CLOUDFLARE_TUNNEL_TOKEN=
#### FOR KOPIA ####
KOPIA_SERVER_USERNAME_HOST=admin@hostname
KOPIA_SERVER_PASSWORD=
KOPIA_REPO_PASSWORD=
#### FOR WUD ####
# Set the check-in seconds (86400 = 1 day)
WUD_POLL_CYCLE=21600
#### ADD THESE LINES FOR LINUXSERVER.IO ####
GITHUB_USER=
GITHUB_TOKEN=
#### ADD THESE LINES FOR DOCKERHUB ####
DOCKERHUB_USER=
DOCKERHUB_TOKEN=
#### For Gotify Trigger ####
GOTIFY_TOKEN=
#### Basic Auth ####
WUD_NAME_USER=
WUD_NAME_HASH=
#### FOR HOMEPAGE ####
HOMEPAGE_VAR_JELLYFIN_KEY=
HOMEPAGE_VAR_JELLYSEERR_KEY=
HOMEPAGE_VAR_RADARR_KEY=
HOMEPAGE_VAR_SONARR_KEY=
HOMEPAGE_VAR_BAZARR_KEY=
HOMEPAGE_VAR_PROWLARR_KEY=
HOMEPAGE_VAR_GOTIFY_KEY=
HOMEPAGE_VAR_TRANS_USER=
HOMEPAGE_VAR_TRANS_PASS=
HOMEPAGE_VAR_QBIT_USER=
HOMEPAGE_VAR_QBIT_PASS=
HOMEPAGE_VAR_CROWDSEC_USER=
HOMEPAGE_VAR_CROWDSEC_PASS=
HOMEPAGE_VAR_GLUETUN_KEY=
HOMEPAGE_VAR_BESZEL_USER=
HOMEPAGE_VAR_BESZEL_PASS=
HOMEPAGE_VAR_BESZEL_SYSID=
HOMEPAGE_VAR_KOPIA_USERNAME_HOST=admin@host
HOMEPAGE_VAR_KOPIA_PASS=
HOMEPAGE_VAR_AUTHENTIK_API_TOKEN=
HOMEPAGE_VAR_ROOT_DOMAIN=
HOMEPAGE_VAR_SPEEDTEST_KEY=
HOMEPAGE_VAR_TAILSCALE_DEVICE_ID=
HOMEPAGE_VAR_TAILSCALE_KEY="tskey-api-xxxx"
#### FOR BESZEL ####
MEDIASVR_PUBLIC_KEY="ssh-ed25519 xxxx"
MEDIASVR_TOKEN=
#### FOR ARCANE ####
ARCANE_KEY=
ARCANE_JWT_SECRET=
#### FOR GLUETUN ####
#### AIRVPN - WIREGUARD SETTINGS ####
# Go to AirVPN Client Area -> "WireGuard"
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY==
WIREGUARD_PRESHARED_KEY==
WIREGUARD_ADDRESSES=
SERVER_COUNTRIES=Singapore,Netherlands
# WIREGUARD_MTU=1300
#### AIRVPN PORT FORWARDING (CRITICAL) ####
# AirVPN Client Area -> "Port Forwarding"
# Request Ports and paste the 5-digit number here
AIRVPN_PORT_QBIT=
AIRVPN_PORT_TRANS=
#### TRANSMISSION CREDENTIALS ####
TRANSMISSION_USER=
TRANSMISSION_PASS=''
#### GLUETUN CONTROL SERVER SECURITY ####
# Enable logging for the control server
HTTP_CONTROL_SERVER_LOG=off
#### For SPEED TRACKER SERVICE ####
SPEED_KEY=
SONARR_API_KEY=
RADARR_API_KEY=
Create the actual .env file, either at the root/master level and/or at stack/individual container/services level.
(Note: The .env file is intentionally ignored by .gitignore to keep secrets safe).
Populate the variables
Inside the file, will need to fill in specific environment details. Pay special attention to:
- System Variables:
PUID,PGID, andTZ(Timezone) to ensure containers have the correct file permissions. - Networking: base domain name (e.g.,
domain.com). - Secrets: Cloudflare API tokens, database passwords, and any SSO/authentication keys.
Expand to view current .env template
#### Generic ####
BACKUP_USER=<for_rclone_script>
PUID=1000
PGID=1000
TZ=Asia/Singapore
#### CADDY ####
#### CF DNS Zone API Token for domain.com ####
CLOUDFLARE_API_TOKEN=
#### Crowdsec Bouncer ####
CROWDSEC_API_KEY=
#### Root Domain ####
ROOT_DOMAIN=
#### For Maxmind GeoIP ####
MAXMIND_ACCOUNT_ID=
MAXMIND_LICENSE_KEY=
#### For VOIDAUTH ####
VOIDAUTH_STORAGE_KEY=
VOIDAUTH_DB_PASS=
VOIDAUTH_POSTGRES_PASS=
PASS_STRENGTH=2
APP_TITLE='Title_Goes_Here'
#### FOR JELLYFIN ####
#### CLOUDFLARED - CF TUNNEL IF USING THIS INSTEAD OF CADDY REVERSE PROXY ####
#CLOUDFLARE_TUNNEL_TOKEN=
#### FOR KOPIA ####
KOPIA_SERVER_USERNAME_HOST=admin@hostname
KOPIA_SERVER_PASSWORD=
KOPIA_REPO_PASSWORD=
#### FOR WUD ####
# Set the check-in seconds (86400 = 1 day)
WUD_POLL_CYCLE=21600
#### ADD THESE LINES FOR LINUXSERVER.IO ####
GITHUB_USER=
GITHUB_TOKEN=
#### ADD THESE LINES FOR DOCKERHUB ####
DOCKERHUB_USER=
DOCKERHUB_TOKEN=
#### For Gotify Trigger ####
GOTIFY_TOKEN=
#### Basic Auth ####
WUD_NAME_USER=
WUD_NAME_HASH=
#### FOR HOMEPAGE ####
HOMEPAGE_VAR_JELLYFIN_KEY=
HOMEPAGE_VAR_JELLYSEERR_KEY=
HOMEPAGE_VAR_RADARR_KEY=
HOMEPAGE_VAR_SONARR_KEY=
HOMEPAGE_VAR_BAZARR_KEY=
HOMEPAGE_VAR_PROWLARR_KEY=
HOMEPAGE_VAR_GOTIFY_KEY=
HOMEPAGE_VAR_TRANS_USER=
HOMEPAGE_VAR_TRANS_PASS=
HOMEPAGE_VAR_QBIT_USER=
HOMEPAGE_VAR_QBIT_PASS=
HOMEPAGE_VAR_CROWDSEC_USER=
HOMEPAGE_VAR_CROWDSEC_PASS=
HOMEPAGE_VAR_GLUETUN_KEY=
HOMEPAGE_VAR_BESZEL_USER=
HOMEPAGE_VAR_BESZEL_PASS=
HOMEPAGE_VAR_BESZEL_SYSID=
HOMEPAGE_VAR_KOPIA_USERNAME_HOST=admin@host
HOMEPAGE_VAR_KOPIA_PASS=
HOMEPAGE_VAR_AUTHENTIK_API_TOKEN=
HOMEPAGE_VAR_ROOT_DOMAIN=
HOMEPAGE_VAR_SPEEDTEST_KEY=
HOMEPAGE_VAR_TAILSCALE_DEVICE_ID=
HOMEPAGE_VAR_TAILSCALE_KEY="tskey-api-xxxx"
#### FOR BESZEL ####
MEDIASVR_PUBLIC_KEY="ssh-ed25519 xxxx"
MEDIASVR_TOKEN=
#### FOR ARCANE ####
ARCANE_KEY=
ARCANE_JWT_SECRET=
#### FOR GLUETUN ####
#### AIRVPN - WIREGUARD SETTINGS ####
# Go to AirVPN Client Area -> "WireGuard"
VPN_TYPE=wireguard
WIREGUARD_PRIVATE_KEY==
WIREGUARD_PRESHARED_KEY==
WIREGUARD_ADDRESSES=
SERVER_COUNTRIES=Singapore,Netherlands
# WIREGUARD_MTU=1300
#### AIRVPN PORT FORWARDING (CRITICAL) ####
# AirVPN Client Area -> "Port Forwarding"
# Request Ports and paste the 5-digit number here
AIRVPN_PORT_QBIT=
AIRVPN_PORT_TRANS=
#### TRANSMISSION CREDENTIALS ####
TRANSMISSION_USER=
TRANSMISSION_PASS=''
#### GLUETUN CONTROL SERVER SECURITY ####
# Enable logging for the control server
HTTP_CONTROL_SERVER_LOG=off
#### For SPEED TRACKER SERVICE ####
SPEED_KEY=
SONARR_API_KEY=
RADARR_API_KEY=
Bootstrap CrowdSec (Key Generation)
API key for Caddy
Before starting the full stack, we need to generate an API key for Caddy.
# 1. Start CrowdSec only
cd gateway-stack/crowdsec && docker compose up -d
# 2. Generate Caddy Key
docker exec crowdsec cscli bouncers add caddy-bouncer
Copy the generated key and paste it into our Master .env file under CROWDSEC_API_KEY.
Start All Stacks
Run the script to bring up the entire infrastructure in the correct order.
# Return to root
cd /mnt/pool01/homelab/services
# Launch
./scripts/recreate-all.sh
Restore "Ignored" Assets
If we have local backups, restore these specific files that are ignored by Git to prevent data leaks or overwrites:
gateway-stack/voidauth/config/(Logos/Branding)mon-stack/homepage/icons/(Custom PNGs)