Skip to content

j-about/PiHomeLab

Repository files navigation

PiHomeLab

License: MIT Platform Automation Runtime

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.

Table of contents

Overview

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.

Architecture

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
Loading

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

Repository layout

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)

Prerequisites

Control machine

  • Ansible (ansible-core) with the community.docker and ansible.posix collections:
    ansible-galaxy collection install community.docker ansible.posix
  • rsync — used by ansible.posix.synchronize to sync the project (needed on both ends).
  • direnv (optional) — auto-loads .env into your shell.

Raspberry Pi

  • 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_USER with SSH key access, membership of the docker group, and sudo privileges (the playbooks run with become: 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_forward on the host, and each VPN container also sets it in its own namespace (the x-vpn-common sysctls in compose.yaml) — so no manual change is usually needed. Confirm with sysctl net.ipv4.ip_forward (expect 1).

Configuration

Clone the repository and create your environment file from the template:

gh repo clone j-about/PiHomeLab
cd PiHomeLab
cp .env.example .env

Then 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).

Component selection

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

Control machine → Raspberry Pi (Ansible/SSH)

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

LAN / network topology

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

AdGuard Home

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

VPN client (client-chain)

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

EasyRSA / PKI identity (shared by every VPN server)

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

VPN server — via VPN client (client-chain, full tunnel)

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

VPN server — via ISP (isp, full tunnel)

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

VPN server — local (local, split tunnel)

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

Selecting profiles

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.

VPN client configuration files

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.

Deployment

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.yml

A 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.yml

These deploy their component unconditionally; use playbooks/site.yml above to deploy by profile.

Service access

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.

Accessing the Pi over the VPN

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.sh

Bring 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.

Security

  • Secrets stay local. .env is git-ignored — never commit it. The [secret] values are VPN_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, and tls-auth; OpenVPN drops to nobody:nogroup. The generated client .ovpn bundles the client key — protect it like a password.
  • Inbound exposure. The isp and local servers accept connections from the Internet and must be port-forwarded on your router to their *_STATIC_IP. Publishing the client-chain server on 443/tcp helps it blend in with HTTPS. Use a strong upstream VPN_USERNAME / VPN_PASSWORD.

Operations and maintenance

  • 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's conf/ volume is populated.
  • Where state lives. Prometheus and Grafana use the prometheus-data and grafana-storage named volumes; AdGuard Home persists to adguardhome/{work,conf}; each VPN server keeps its PKI in its own conf/; provider profiles live in vpn-client/configs/.
  • Generated client profile. Each VPN bootstrap writes <EASYRSA_CLIENT_NAME>.ovpn into the server's conf/ 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.

License

Released under the MIT License — © 2026 Jonathan About.

About

Modular deployable homelab stack for Raspberry Pi 5: monitoring, networking and self-hosted services.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors