Modular, profile-driven homelab stack for the Raspberry Pi 5 — monitoring, network-wide DNS filtering, and OpenVPN remote access — deployed with Ansible and run with Docker Compose.
- Overview
- Architecture
- Repository layout
- Prerequisites
- Configuration
- Deployment
- Service access
- Accessing the Pi over the VPN
- Security
- Operations and maintenance
- License
PiHomeLab runs a set of self-hosted services on a Raspberry Pi 5. From a control machine, Ansible deploys them over SSH and drives Docker Compose on the Pi. Every component is gated behind a Docker Compose profile and has its own playbook, so you deploy only what you need.
The five profiles are:
| Profile | Services activated | Purpose |
|---|---|---|
monitoring |
node_exporter + cadvisor + prometheus + grafana |
Host metrics (Node Exporter), container metrics (cAdvisor), time-series storage (Prometheus), and dashboards (Grafana). |
adguardhome |
adguardhome |
Network-wide DNS with ad/tracker filtering. Useful on its own, and started automatically by the isp profile (which depends on it). |
client-chain |
vpn-client + vpn-server-via-vpn-client |
An outbound tunnel to a commercial VPN provider, plus a remote-access server whose egress is full-tunnelled through that client. |
isp |
vpn-server-via-isp (+ adguardhome) |
Remote-access server, full tunnel: client Internet traffic exits via your home ISP. Pushes AdGuard Home as the DNS resolver. |
local |
vpn-server-local |
Remote-access server, split tunnel: clients reach the home LAN through PiHomeLab while their own Internet path is left untouched. |
The control machine never installs anything on the Pi beyond what Ansible orchestrates: it syncs
the project to /opt/pihomelab/ and runs docker compose with the requested profile. Services
split across three network types — a bridge for the monitoring stack, the host network for
Node Exporter, and a macvlan network that gives the DNS and VPN containers their own real IPs
on your LAN.
flowchart TB
CM["Control machine<br/>Ansible · direnv"] -->|"SSH · RPI_USER@RPI_IP"| PI
subgraph PI["Raspberry Pi 5 · Docker Compose"]
direction TB
NE["node_exporter<br/>network_mode: host"]
subgraph BR["bridge · monitoring-network"]
CAD["cAdvisor :8080"]
PROM["Prometheus :9090"]
GRAF["Grafana :3000"]
end
subgraph MV["macvlan · lan-network · parent eth0"]
AGH["AdGuard Home"]
VC["vpn-client"]
VVC["vpn-server-via-vpn-client"]
VISP["vpn-server-via-isp"]
VL["vpn-server-local"]
end
PROM --> NE
PROM --> CAD
GRAF --> PROM
VVC -.->|egress via tunnel| VC
end
The macvlan network is defined as subnet: ${NETWORK}/${CIDR}, gateway: ${GATEWAY}, parent
interface eth0. Each LAN-facing container takes a fixed address; the suggested allocation (which
must sit on your subnet, outside the router's DHCP pool, and be unique) is:
| Example IP | Host / container | Variable |
|---|---|---|
192.168.1.201 |
Raspberry Pi (host — Ansible/SSH target) | RPI_IP |
192.168.1.202 |
macvlan host shim (optional) | RPI_MACVLAN_STATIC_IP |
192.168.1.203 |
AdGuard Home | ADGUARD_STATIC_IP |
192.168.1.204 |
vpn-client | VPN_CLIENT_STATIC_IP |
192.168.1.205 |
vpn-server-via-vpn-client | VPN_SERVER_VIA_VPN_CLIENT_STATIC_IP |
192.168.1.206 |
vpn-server-via-isp | VPN_SERVER_VIA_ISP_STATIC_IP |
192.168.1.207 |
vpn-server-local | VPN_SERVER_LOCAL_STATIC_IP |
PiHomeLab/
├── ansible.cfg # Ansible config (inventory + roles path)
├── inventory.yml # Single host "raspberry"; reads RPI_* from the environment
├── .env.example # Annotated configuration template → copy to .env
├── compose.yaml # Services, the macvlan/bridge networks, and the profiles
├── prometheus.yml # Prometheus scrape configuration
├── grafana/provisioning/ # Grafana datasource (Prometheus, pre-provisioned)
├── adguardhome/ # AdGuard Home state (work/ + conf/, bind-mounted)
├── vpn-client/ # Outbound client image + configs/ (.ovpn files)
├── vpn-server-via-vpn-client/ # client-chain server image, bootstrap + PKI (conf/)
├── vpn-server-via-isp/ # isp server image, bootstrap + PKI (conf/)
├── vpn-server-local/ # local server image, bootstrap + PKI (conf/)
├── playbooks/ # site.yml (profile-driven) + 01_monitoring … 05_vpn_local
└── roles/ # common, monitoring, adguardhome, vpn_* (Compose deploy)
- Ansible (
ansible-core) with thecommunity.dockerandansible.posixcollections:ansible-galaxy collection install community.docker ansible.posix
- rsync — used by
ansible.posix.synchronizeto sync the project (needed on both ends). - direnv (optional) — auto-loads
.envinto your shell.
- Raspberry Pi OS (64-bit) / Debian Bookworm.
- Docker Engine with the Compose v2 plugin — install per Docker's official instructions.
- A login user matching
RPI_USERwith SSH key access, membership of thedockergroup, andsudoprivileges (the playbooks run withbecome: true). - IP forwarding must be enabled on the host for the VPN servers to route traffic. In practice
it already is — Docker enables
net.ipv4.ip_forwardon the host, and each VPN container also sets it in its own namespace (thex-vpn-commonsysctls incompose.yaml) — so no manual change is usually needed. Confirm withsysctl net.ipv4.ip_forward(expect1).
Clone the repository and create your environment file from the template:
gh repo clone j-about/PiHomeLab
cd PiHomeLab
cp .env.example .envThen edit .env. It is heavily annotated; the tables below mirror its sections.
Legend —
[required]must be set for any deploy ·[required: X]required when profile X is enabled ·[optional]safe to leave at the default ·[secret]credential, key, or PII (never commit).
| Variable | Description | Example |
|---|---|---|
COMPOSE_PROFILES |
[required] Comma-separated list of Compose profiles to deploy. Drives playbooks/site.yml, which deploys exactly the matching components, and governs manual docker compose runs on the Pi. The individual numbered playbooks ignore it and deploy their component unconditionally (see Deployment). |
monitoring,adguardhome,client-chain,isp,local |
These three are read from your shell by Ansible (via direnv) and are not used by Compose.
| Variable | Description | Example |
|---|---|---|
RPI_IP |
[required] The address Ansible connects to. |
192.168.1.201 |
RPI_USER |
[required] SSH user Ansible logs in as. Must exist on the Pi, be in the docker group, and have sudo. |
username |
RPI_SSH_PRIVATE_KEY_FILE |
[required] [secret] Path on the control machine to the SSH private key (~ expands). |
~/.ssh/privatekeyfile |
Consumed by Compose for the macvlan network and the VPN route pushes. CIDR, NETWORK, NETMASK,
and GATEWAY must all describe the subnet your Pi sits on.
| Variable | Description | Example |
|---|---|---|
CIDR |
[required] Subnet prefix length, no leading slash; must agree with NETMASK. |
24 |
GATEWAY |
[required] Default gateway (your router). |
192.168.1.254 |
NETWORK |
[required] Subnet base address; also pushed as a route to VPN clients. |
192.168.1.0 |
NETMASK |
[required] Subnet mask matching CIDR. |
255.255.255.0 |
RPI_MACVLAN_STATIC_IP |
[optional] Used only by the manual macvlan shim (see Accessing the Pi over the VPN). Not consumed by Ansible or Compose. |
192.168.1.202 |
| Variable | Description | Example |
|---|---|---|
ADGUARD_STATIC_IP |
[required: adguardhome/isp] Static LAN IP of the AdGuard container; reachable at http://<this-ip> and pushed as the DNS resolver to isp VPN clients. |
192.168.1.203 |
| Variable | Description | Example |
|---|---|---|
VPN_USERNAME |
[required: client-chain] [secret] Upstream VPN provider username. |
CHANGE_ME |
VPN_PASSWORD |
[required: client-chain] [secret] Upstream VPN provider password. |
CHANGE_ME |
VPN_CLIENT_STATIC_IP |
[required: client-chain] Static LAN IP of the vpn-client container; the client-chain server routes its egress through this address. |
192.168.1.204 |
VPN_CLIENT_DNS_1 |
[required: client-chain] Primary DNS used inside the client tunnel. |
103.86.96.100 |
VPN_CLIENT_DNS_2 |
[required: client-chain] Secondary DNS used inside the client tunnel. |
103.86.99.100 |
| Variable | Description | Example |
|---|---|---|
EASYRSA_REQ_CN |
[required: client-chain/isp/local] [secret] Common Name written into every issued certificate. Treat as PII. |
Your Name |
EASYRSA_CLIENT_NAME |
[required: client-chain/isp/local] Basename of the generated client cert/key and its .ovpn (lowercase, no spaces). |
your-client |
| Variable | Description | Example |
|---|---|---|
VPN_SERVER_VIA_VPN_CLIENT_EASYRSA_SERVER_NAME |
[required: client-chain] Server cert/key name (kebab-case). |
vpn-server-via-vpn-client |
VPN_SERVER_VIA_VPN_CLIENT_STATIC_IP |
[required: client-chain] Static LAN IP of this server. |
192.168.1.205 |
VPN_SERVER_VIA_VPN_CLIENT_PORT |
[required: client-chain] Listening port (e.g. 443 to blend in with HTTPS). |
443 |
VPN_SERVER_VIA_VPN_CLIENT_PROTOCOL |
[required: client-chain] Transport — tcp or udp. |
tcp |
VPN_SERVER_VIA_VPN_CLIENT_HOST |
[required: client-chain] Hostname clients connect to (FQDN or IP). |
vpn1.example.com |
VPN_SERVER_VIA_VPN_CLIENT_NETWORK |
[required: client-chain] VPN tunnel subnet; must not overlap the LAN or another tunnel. |
192.168.2.0 |
VPN_SERVER_VIA_VPN_CLIENT_NETMASK |
[required: client-chain] VPN tunnel netmask. |
255.255.255.0 |
| Variable | Description | Example |
|---|---|---|
VPN_SERVER_VIA_ISP_EASYRSA_SERVER_NAME |
[required: isp] Server cert/key name (kebab-case). |
vpn-server-via-isp |
VPN_SERVER_VIA_ISP_STATIC_IP |
[required: isp] Static LAN IP of this server. |
192.168.1.206 |
VPN_SERVER_VIA_ISP_PORT |
[required: isp] Listening port; port-forward it on your router to the static IP. |
1194 |
VPN_SERVER_VIA_ISP_PROTOCOL |
[required: isp] Transport — tcp or udp. |
tcp |
VPN_SERVER_VIA_ISP_HOST |
[required: isp] Public hostname clients connect to (FQDN or public IP). |
vpn2.example.com |
VPN_SERVER_VIA_ISP_NETWORK |
[required: isp] VPN tunnel subnet; must not overlap the LAN or another tunnel. |
192.168.3.0 |
VPN_SERVER_VIA_ISP_NETMASK |
[required: isp] VPN tunnel netmask. |
255.255.255.0 |
| Variable | Description | Example |
|---|---|---|
VPN_SERVER_LOCAL_EASYRSA_SERVER_NAME |
[required: local] Server cert/key name (kebab-case). |
vpn-server-local |
VPN_SERVER_LOCAL_STATIC_IP |
[required: local] Static LAN IP of this server. |
192.168.1.207 |
VPN_SERVER_LOCAL_PORT |
[required: local] Listening port; port-forward it on your router to the static IP. |
1194 |
VPN_SERVER_LOCAL_PROTOCOL |
[required: local] Transport — tcp or udp. |
udp |
VPN_SERVER_LOCAL_HOST |
[required: local] Public hostname clients connect to (FQDN or public IP). |
vpn3.example.com |
VPN_SERVER_LOCAL_NETWORK |
[required: local] VPN tunnel subnet; must not overlap the LAN or another tunnel. |
192.168.4.0 |
VPN_SERVER_LOCAL_NETMASK |
[required: local] VPN tunnel netmask. |
255.255.255.0 |
Set COMPOSE_PROFILES to the components you want. adguardhome has its own profile, but the isp
profile depends on it and starts it automatically.
COMPOSE_PROFILES="monitoring,adguardhome,client-chain,isp,local" # everything (default)
COMPOSE_PROFILES="monitoring,adguardhome" # monitoring + DNS only
COMPOSE_PROFILES="monitoring,local" # monitoring + LAN-only VPN
COMPOSE_PROFILES="client-chain,isp" # outbound chain + ISP-exposed access (pulls in DNS)This value drives playbooks/site.yml (the profile-driven entrypoint, see Deployment)
and governs manual docker compose runs on the Pi. The individual numbered playbooks activate
the matching profile themselves, so each deploys its component regardless of the .env value.
For the client-chain profile, place your provider's .ovpn files in vpn-client/configs/. The
client picks one at random on each (re)connect and authenticates with VPN_USERNAME / VPN_PASSWORD.
The recommended entrypoint is playbooks/site.yml: it reads COMPOSE_PROFILES from your
environment (exported from .env by direnv) and deploys exactly the components matching the
selected profiles, pulling in prerequisites automatically (adguardhome for isp). With
the default .env value it deploys the full stack.
ansible-playbook playbooks/site.ymlA shared common role (a dependency of every component) syncs compose.yaml and .env to
/opt/pihomelab/ first. Each component also keeps its own playbook for targeted, profile-independent
runs:
| Playbook | Component | Profile(s) activated |
|---|---|---|
playbooks/01_monitoring.yml |
Monitoring stack | monitoring |
playbooks/02_adguardhome.yml |
AdGuard Home DNS | adguardhome |
playbooks/03_vpn_client_chain.yml |
Client-chain VPN | client-chain |
playbooks/04_vpn_isp.yml |
ISP VPN server (also deploys AdGuard Home) | isp (+ adguardhome) |
playbooks/05_vpn_local.yml |
LAN-only VPN server | local |
Run only the ones you need:
ansible-playbook playbooks/01_monitoring.yml
ansible-playbook playbooks/02_adguardhome.yml
ansible-playbook playbooks/03_vpn_client_chain.yml
ansible-playbook playbooks/04_vpn_isp.yml
ansible-playbook playbooks/05_vpn_local.ymlThese deploy their component unconditionally; use playbooks/site.yml above to deploy by profile.
| Service | URL / address | Notes |
|---|---|---|
| Grafana | http://<RPI_IP>:3000 |
Ships with Prometheus pre-provisioned as the default data source. |
| Prometheus | http://<RPI_IP>:9090 |
|
| cAdvisor | http://<RPI_IP>:8080 |
|
| AdGuard Home | http://<ADGUARD_STATIC_IP> |
First-run setup wizard is on :3000; the dashboard moves to port 80 afterwards. |
| VPN servers | <VPN_SERVER_*_HOST>:<…_PORT> |
Import the generated .ovpn. The isp and local servers need a router port-forward to their *_STATIC_IP. |
The monitoring UIs and SSH run on the Pi host itself (reached via RPI_IP), so over a VPN
connection they are reachable only if you set up the optional shim below.
Optional. Only needed if you want to reach services on the Pi host itself — Grafana, Prometheus, cAdvisor, SSH — while connected to one of the VPN servers.
Each VPN server pushes a route to your home LAN, so connected clients can already reach the
containers on the macvlan network (AdGuard Home and the VPN servers themselves). The Pi host
is the exception: Docker's macvlan driver deliberately blocks traffic between a host and the
containers on its own macvlan network, so a macvlan container — and therefore a VPN client behind
it — cannot reach the host at RPI_IP.
A host-side macvlan shim gives the Pi a presence on that network. With it up, reach the Pi's
host services at the shim IP (RPI_MACVLAN_STATIC_IP) while connected to a VPN.
Create the shim script (it sources the deployed .env, so addresses stay in lock-step with your
configuration):
sudo tee /usr/local/bin/macvlan-shim.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
# Load the deployed configuration (RPI_MACVLAN_STATIC_IP, CIDR).
set -a; . /opt/pihomelab/.env; set +a
# Create a macvlan sub-interface on the LAN NIC.
ip link add macvlan0 link eth0 type macvlan mode bridge
# Announce the interface via gratuitous ARP on every bring-up or MAC change.
sysctl -wq net.ipv4.conf.macvlan0.arp_notify=1
# Assign the shim's address on the LAN subnet.
ip addr add "${RPI_MACVLAN_STATIC_IP}/${CIDR}" dev macvlan0
# Bring the interface up.
ip link set macvlan0 up
EOF
sudo chmod +x /usr/local/bin/macvlan-shim.shBring it up now and re-create it on every boot (the interface is not persistent):
sudo /usr/local/bin/macvlan-shim.sh
( sudo crontab -l 2>/dev/null; echo '@reboot /usr/local/bin/macvlan-shim.sh' ) | sudo crontab -RPI_MACVLAN_STATIC_IP must be on your LAN subnet, outside the DHCP pool, and unique.
- Secrets stay local.
.envis git-ignored — never commit it. The[secret]values areVPN_USERNAME,VPN_PASSWORD,EASYRSA_REQ_CN(PII, baked into every certificate), and your SSH private key. - Strong VPN PKI by default. Each server is bootstrapped with a 4096-bit RSA CA,
AES-256-GCM,SHA512, andtls-auth; OpenVPN drops tonobody:nogroup. The generated client.ovpnbundles the client key — protect it like a password. - Inbound exposure. The
ispandlocalservers accept connections from the Internet and must be port-forwarded on your router to their*_STATIC_IP. Publishing theclient-chainserver on443/tcphelps it blend in with HTTPS. Use a strong upstreamVPN_USERNAME/VPN_PASSWORD.
- Idempotent re-runs. Re-running a playbook re-syncs the project, re-applies the Compose state
(
state: present), and pulls the latest images (pull: always). The one-shot PKI bootstrap skips itself once a server'sconf/volume is populated. - Where state lives. Prometheus and Grafana use the
prometheus-dataandgrafana-storagenamed volumes; AdGuard Home persists toadguardhome/{work,conf}; each VPN server keeps its PKI in its ownconf/; provider profiles live invpn-client/configs/. - Generated client profile. Each VPN bootstrap writes
<EASYRSA_CLIENT_NAME>.ovpninto the server'sconf/directory on the Pi (e.g./opt/pihomelab/vpn-server-local/conf/). - Regenerating PKI. Empty the relevant
conf/directory and re-run its playbook; the bootstrap re-issues the CA, server, and client certificates from scratch.
Released under the MIT License — © 2026 Jonathan About.