Skip to content

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.

Steps and more details at Cloudflare Section here

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.

Steps and more details at Networking Section here.


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, and TZ (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)