This article is written for anyone giving a talk—or quietly building a lab—about owning your data, reducing noise on the network, and reaching your services safely without turning your router into a public punching bag. We will use a Raspberry Pi, Docker Compose, Tailscale on the host, Pi-hole for DNS filtering, and Nextcloud for files and lightweight collaboration.
The goal is not to replicate a data center. The goal is a small, understandable system you can reason about, patch, and back up.
Who this is for and what you get
You might be a developer who wants sync and calendars without a cloud bill, or a curious tinkerer who wants to learn networking and containers on real hardware. Either way, you get:
- Pi-hole: DNS-based ad blocking and telemetry reduction for your home (and optionally for devices on your tailnet).
- Nextcloud: Self-hosted files, sync, and optional apps (calendar, contacts, and more).
- Tailscale: A mesh VPN so you reach the Pi without exposing SSH or web UIs to the whole internet.
Keep Pi-hole and Nextcloud in separate folders on the Pi (each with its own docker-compose.yml and .env). Tailscale is installed on the Raspberry Pi OS host, not inside those Compose files: the Pi joins your tailnet; Docker runs the services on top.
A light threat model (what you are actually protecting)
Tailscale encrypts traffic between your devices and authenticates them to your tailnet. It does not replace:
- Strong passwords for Pi-hole admin, Nextcloud admin, and database users.
- Timely updates to the OS, Docker images, and Nextcloud apps.
- Backups of volumes (you will lose data if the SD card dies with no backup).
Pi-hole blocks many ads and trackers at DNS resolution. It does not replace a full security stack on clients; malware and phishing still exist.
Nextcloud is a rich PHP application with a large surface area. Treat it like any internet-facing app: least privilege, updates, and backups.
In short: Tailscale shrinks who can reach your Pi; Pi-hole and Nextcloud still need good hygiene once someone is on the tailnet.
Hardware and OS choices
Raspberry Pi 4 or 5 with 64-bit Raspberry Pi OS is a solid baseline. Nextcloud benefits from RAM (4 GB or more helps) and fast storage: an external SSD over USB 3 is dramatically better than a slow SD card for database I/O and file sync.
If you use an SD card, expect more maintenance: monitor wear, keep backups, and avoid large Nextcloud sync jobs that thrash the card all day.
Architecture: how the pieces fit together
Tailscale runs on the Pi host. Docker runs Pi-hole and Nextcloud as separate Compose projects (or on separate Pis if you prefer). Traffic flows from your laptop or phone, through the encrypted mesh, to the Pi, then into the right container.
Why Tailscale on the host? Your Pi’s Tailscale IP and MagicDNS name apply to services bound to the host’s interfaces. You can publish ports from Docker to the host; Tailscale clients then reach 100.x.y.z:port or pi-name.tailnet-name.ts.net:port without extra container networking gymnastics. For advanced setups (e.g. container-only routing), Tailscale documents Docker sidecars; for most home labs, host Tailscale + Docker bridge networking is enough.
Install Docker Engine and Compose on the Pi
Use the official Docker documentation for Debian (Raspberry Pi OS is Debian-based). Install the Docker Engine and the Compose plugin so you can run:
docker compose up -d
Keep your working directory layout predictable, for example:
~/homelab/
pihole/
nextcloud/
Each stack has its own docker-compose.yml and a .env file (start from the templates in this article). Keep .env private—do not share or publish it.
Step-by-step: from a fresh Pi to both stacks
Follow these in order. The full Docker files for copy-paste are in the next two sections.
-
Flash and boot 64-bit Raspberry Pi OS; create a user; run
sudo apt update && sudo apt full-upgrade -y. -
Install Docker Engine and the Compose plugin using Docker’s Debian instructions (Raspberry Pi OS is Debian-based). Add your user to the
dockergroup, then sign out and back in sodockerworks withoutsudo. -
Install Tailscale on the host (not inside Compose): follow Tailscale’s Linux install, run
sudo tailscale up, and complete browser login. Note your Tailscale IPv4 withtailscale ip -4and your MagicDNS hostname if you use it. -
Resolve port 53 before Pi-hole: Pi-hole needs TCP/UDP 53 on the host. If
systemd-resolvedholds the port, reconfigure or disable the stub listener (see DNS, port 53, and systemd-resolved below). Verify nothing else is bound withss -lunp | grep ':53'or similar. -
Deploy Pi-hole: create a folder (e.g.
mkdir -p ~/homelab/pihole && cd ~/homelab/pihole), add the Pi-holedocker-compose.ymland.envcontents from Pi-hole: full Docker files (save the env template as.envand replace placeholders). -
Start Pi-hole:
docker compose up -d, then check logs withdocker compose logs -fbriefly. Open the admin UI athttp://<pi-tailscale-ip>:8080/admin. -
Deploy Nextcloud: create a folder (e.g.
mkdir -p ~/homelab/nextcloud && cd ~/homelab/nextcloud), add the Nextclouddocker-compose.ymland.envcontents from Nextcloud: full Docker files (save the env template as.envand replace placeholders). -
Start Nextcloud:
docker compose up -d. Wait until MariaDB and Redis report healthy, then openhttp://<pi-tailscale-ip>:8081and sign in with the admin user from.env(first boot performs an unattended install when those variables are set). -
Fix “untrusted domain” if needed: add every hostname or IP you use in the browser to
NEXTCLOUD_TRUSTED_DOMAINS(space-separated), thendocker compose restart appor adjust viaoccas documented by Nextcloud. - Restrict Tailscale: in the Tailscale admin console, tune ACLs so only your user or devices can reach SSH, Pi-hole (53, 8080), and Nextcloud (8081).
- Set Pi-hole upstream DNS in the web UI (Settings → DNS) to resolvers you trust; optional DoH/DoT upstreams depend on your Pi-hole version and docs.
- Back up Docker volumes (or export data) before you rely on the lab; test a restore once.
Screenshots: Pi-hole and Nextcloud over Tailscale
Below is what a working setup looks like in the browser when you use your Pi’s Tailscale IP (addresses in the 100.64.0.0/10 range, shown as 100.x.x.x). Plain HTTP is fine on a private tailnet for testing; the browser may still label the connection “Not secure” until you add HTTPS (for example Tailscale Serve or a reverse proxy with a certificate).
Pi-hole admin dashboard
![Pi-hole admin dashboard: DNS totals, blocked queries, 24-hour charts, and sidebar status — accessed at http://100.x.x.x:8080/admin over Tailscale]
The Pi-hole UI shows aggregate queries, blocks, percentage blocked, and per-client activity. The sidebar lists Status, Performance, and Hostname (Docker often shows a container ID there instead of the Pi’s host name). Your URL will look like http://<your-pi-tailscale-ip>:8080/admin.
Nextcloud Files
![Nextcloud Files app: All files, folders, and apps bar — accessed at http://100.x.x.x:8081 over Tailscale]
Nextcloud is mapped to host port 8081 in this guide (NEXTCLOUD_HTTP_PORT). The Files app lists default folders (Documents, Photos, and so on) and the top bar links to other apps (Photos, Talk, Contacts, Calendar). Storage usage appears in the left sidebar.
Pi-hole: full Docker files
Save these as docker-compose.yml and .env in the same directory (e.g. ~/homelab/pihole). Copy the env template to .env and replace placeholder values.
docker-compose.yml
# Pi-hole on Raspberry Pi (ARM64). Install Tailscale on the host, not in this file.
# Port 53: stop or reconfigure systemd-resolved on the Pi before binding (see README in blog).
# Copy .env.example to .env and set WEBPASSWORD + TZ.
services:
pihole:
image: pihole/pihole:latest
container_name: pihole
hostname: pihole
platform: linux/arm64
ports:
# DNS — requires nothing else listening on 53 on the host (see blog: systemd-resolved)
- '53:53/tcp'
- '53:53/udp'
# Web UI — 8080 avoids clashing with other HTTP services on the Pi
- '8080:80/tcp'
# Optional: HTTPS to admin UI (Pi-hole generates cert for its UI when enabled)
- '8443:443/tcp'
env_file:
- .env
volumes:
- pihole_etc:/etc/pihole
- pihole_dnsmasq:/etc/dnsmasq.d
restart: unless-stopped
cap_add:
- NET_ADMIN
networks:
- pihole_net
volumes:
pihole_etc:
pihole_dnsmasq:
networks:
pihole_net:
driver: bridge
.env (template; copy from this example)
# Copy to .env and adjust. Do not commit .env.
# Strong password for the Pi-hole web UI
WEBPASSWORD=change-me-to-a-long-random-string
# IANA timezone, e.g. America/New_York, Europe/London
TZ=UTC
Commands
cd ~/homelab/pihole # or your path
docker compose up -d
Pi-hole: DNS filtering without exposing the whole LAN
What Pi-hole does
Pi-hole answers DNS queries from your network. Blocklists cause ad and tracker domains to resolve to nowhere (or a sinkhole), which cuts noise for every device that uses Pi-hole as its DNS server.
DNS, port 53, and systemd-resolved
Pi-hole needs TCP and UDP port 53 on the host. On many Debian-based systems, systemd-resolved already listens on 127.0.0.53:53. You must either:
- Stop or reconfigure the stub resolver so port 53 is free for Docker to bind, or
- Use a different deployment (for example host networking) with full understanding of the tradeoffs.
This is the most common “it works on my laptop but not on the Pi” issue. Plan for ten minutes of reading your OS docs and verifying with ss -ulnp | grep ':53' or similar.
Open http://<pi-tailscale-ip>:8080/admin from a device on your tailnet (after you restrict access with ACLs, described below).
Upstream DNS and privacy
Pi-hole forwards queries it does not block to upstream resolvers. You can choose:
- Your ISP’s DNS (simple, less privacy from the ISP).
- DNS over HTTPS (DoH) or DNS over TLS (DoT) to a provider you trust (better against casual snooping on the path; you still trust the provider).
There is no single “correct” answer; document what you chose and why when you present this to others.
Nextcloud: full Docker files
Save these as docker-compose.yml and .env in the same directory (e.g. ~/homelab/nextcloud). The app listens on host port 8081 by default so it does not clash with Pi-hole on 8080.
docker-compose.yml
# Nextcloud + MariaDB + Redis for Raspberry Pi (ARM64). Tailscale runs on the host.
# Web UI on host port 8081 (change if it clashes with Pi-hole or other services).
# Copy .env.example to .env before `docker compose up -d`.
services:
db:
image: mariadb:11
container_name: nextcloud-db
platform: linux/arm64
command: >-
--transaction-isolation=READ-COMMITTED
--log-bin=binlog
--binlog-format=ROW
volumes:
- nextcloud_db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
restart: unless-stopped
healthcheck:
test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
start_period: 20s
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: nextcloud-redis
platform: linux/arm64
restart: unless-stopped
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
app:
image: nextcloud:latest
container_name: nextcloud
platform: linux/arm64
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
- '${NEXTCLOUD_HTTP_PORT:-8081}:80'
volumes:
- nextcloud_app_data:/var/www/html
environment:
MYSQL_HOST: db
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
REDIS_HOST: redis
# Space-separated list (see Nextcloud docker entrypoint)
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
restart: unless-stopped
volumes:
nextcloud_db_data:
nextcloud_app_data:
.env (template; copy from this example)
# Copy to .env and set strong passwords. Do not commit .env.
# MariaDB
MYSQL_ROOT_PASSWORD=change-me-root
MYSQL_DATABASE=nextcloud
MYSQL_USER=nextcloud
MYSQL_PASSWORD=change-me-db-user
# First admin user (required for unattended install on first boot)
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=change-me-admin
# Space-separated hostnames or IPs allowed in trusted_domains (not commas)
# Add your Pi Tailscale MagicDNS name (e.g. pi.tail1234.ts.net) and 100.x.x.x as needed
NEXTCLOUD_TRUSTED_DOMAINS="localhost 127.0.0.1"
# Host port mapped to container :80 (default 8081 to avoid clashing with Pi-hole on 8080)
NEXTCLOUD_HTTP_PORT=8081
Commands
cd ~/homelab/nextcloud # or your path
docker compose up -d
Nextcloud: your files on hardware you control
Stack overview
This stack includes:
- MariaDB for the application database (with a healthcheck so the app starts after the DB is ready).
- Redis for PHP session storage (recommended for a smoother experience under load).
- Nextcloud itself, with data in a named volume.
The web UI is published on host port 8081 by default (NEXTCLOUD_HTTP_PORT in .env) so it does not collide with Pi-hole’s 8080.
First-time install and trusted domains
The official Nextcloud image can perform an unattended install if you set, before the first start:
-
MYSQL_*variables pointing at thedbservice. -
NEXTCLOUD_ADMIN_USERandNEXTCLOUD_ADMIN_PASSWORD. -
NEXTCLOUD_TRUSTED_DOMAINSas a space-separated list (not comma-separated). The image’s entrypoint loops over each token and runsocc config:system:set trusted_domains.
Add your Pi’s Tailscale MagicDNS name and 100.x address once you know them, for example:
NEXTCLOUD_TRUSTED_DOMAINS="localhost 127.0.0.1 mypi.tailabc123.ts.net 100.101.102.103"
HTTPS without opening the router
You have several good patterns:
- Tailscale Serve can terminate HTTPS for a local service and make it available on your tailnet with a proper certificate story for Tailscale’s model. This avoids port-forwarding on your home router.
- A reverse proxy on the host (Caddy, Traefik, nginx) with Let’s Encrypt is classic when you have a public hostname and ports 80/443 forwarded.
For a talk aimed at “secure home lab,” leading with Tailscale + Serve keeps the story tight: no inbound ports on the ISP side if you do not want them.
Tailscale: tailnet, MagicDNS, and ACLs
Join the Pi to your tailnet
Install Tailscale on Raspberry Pi OS using Tailscale’s documented steps, authenticate once, and enable MagicDNS if you like human-readable names such as pi.tailnet.ts.net.
Access control lists (ACLs)
By default, Tailscale’s admin console lets you write ACLs that describe which users and devices can reach which ports on which hosts. For a lab Pi, you might:
- Allow only your user to SSH to the Pi.
- Allow only your user to hit Pi-hole’s web UI and DNS.
- Allow only your user to use Nextcloud.
Example shape (illustrative only; adapt tags and users to your tailnet):
{
"acls": [
{
"action": "accept",
"src": ["group:homelab-admins"],
"dst": ["tag:pi-hole:53", "tag:pi-hole:8080", "tag:nextcloud:8081"]
}
]
}
In practice you will use auto tags, device names, or IP addresses as your org defines. The important idea for your audience: Tailscale is not “everything can reach everything.” You still design least privilege.
Operational security: updates, backups, and firewall
Updates
-
Host:
aptupgrades for the OS; reboot when the kernel requires it. -
Docker:
docker compose pullanddocker compose up -dper stack; read Nextcloud release notes before major jumps. - Nextcloud apps: update from the Nextcloud UI on a schedule you can sustain.
Backups
Back up named volumes or the host paths behind them. A simple pattern is stopping the stack (brief downtime) and archiving volumes, or using a file-level backup tool that understands Docker. Test restores on a spare SD card or another Pi before you rely on backups.
Host firewall
If you only ever access services over Tailscale, you can use ufw or nftables on the Pi to drop inbound traffic on LAN and WAN interfaces while allowing Tailscale’s interface. If you also serve LAN clients without Tailscale, your rules differ. Document the two modes so you do not lock yourself out.
Common pitfalls (talk fodder)
| Symptom | Likely cause |
|---|---|
| Pi-hole fails to bind port 53 |
systemd-resolved or another DNS listener still on 53 |
| Nextcloud “untrusted domain” | Missing entry in NEXTCLOUD_TRUSTED_DOMAINS for the hostname you type in the browser |
| Nextcloud install loops or fails | DB not ready; the Compose file uses depends_on with healthchecks to mitigate |
| Slow Nextcloud on Pi | SD card bottleneck; move data and DB to SSD; add RAM |
| Locked out after enabling firewall | SSH allowed only on wrong interface; keep a physical console or time-limited rule |
Before you go live: checklist
- [ ]
.envfiles created from.env.examplewith long random passwords. - [ ] Tailscale ACLs reviewed: only you (or your admin group) reach admin UIs and sensitive ports.
- [ ]
NEXTCLOUD_TRUSTED_DOMAINSincludes every hostname and Tailscale IP you will use. - [ ] Backup plan exists and a restore has been tested once.
- [ ] Pi-hole upstream DNS choice documented (privacy and reliability tradeoffs understood).
- [ ] Router port-forwarding intentionally on or off; if off, Tailscale-only access is the primary path.
Closing
A Raspberry Pi home lab with Tailscale, Pi-hole, and Nextcloud is a practical way to learn real networking, container operations, and defense in depth without a rack full of gear. Keep the architecture boring: Tailscale on the host, Compose for services, strong secrets in .env, ACLs on the tailnet, and backups you have actually restored. That combination is easy to explain on stage and solid enough to live with at home.



Top comments (0)