DEV Community

Cover image for Building a Secure Home Lab on a Raspberry Pi with Tailscale, Pi-hole, and Nextcloud
Felix Jumason
Felix Jumason

Posted on

Building a Secure Home Lab on a Raspberry Pi with Tailscale, Pi-hole, and Nextcloud

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.

flowchart

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
Enter fullscreen mode Exit fullscreen mode

Keep your working directory layout predictable, for example:

~/homelab/
  pihole/
  nextcloud/
Enter fullscreen mode Exit fullscreen mode

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.

  1. Flash and boot 64-bit Raspberry Pi OS; create a user; run sudo apt update && sudo apt full-upgrade -y.
  2. Install Docker Engine and the Compose plugin using Docker’s Debian instructions (Raspberry Pi OS is Debian-based). Add your user to the docker group, then sign out and back in so docker works without sudo.
  3. 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 with tailscale ip -4 and your MagicDNS hostname if you use it.
  4. Resolve port 53 before Pi-hole: Pi-hole needs TCP/UDP 53 on the host. If systemd-resolved holds the port, reconfigure or disable the stub listener (see DNS, port 53, and systemd-resolved below). Verify nothing else is bound with ss -lunp | grep ':53' or similar.
  5. Deploy Pi-hole: create a folder (e.g. mkdir -p ~/homelab/pihole && cd ~/homelab/pihole), add the Pi-hole docker-compose.yml and .env contents from Pi-hole: full Docker files (save the env template as .env and replace placeholders).
  6. Start Pi-hole: docker compose up -d, then check logs with docker compose logs -f briefly. Open the admin UI at http://<pi-tailscale-ip>:8080/admin.
  7. Deploy Nextcloud: create a folder (e.g. mkdir -p ~/homelab/nextcloud && cd ~/homelab/nextcloud), add the Nextcloud docker-compose.yml and .env contents from Nextcloud: full Docker files (save the env template as .env and replace placeholders).
  8. Start Nextcloud: docker compose up -d. Wait until MariaDB and Redis report healthy, then open http://<pi-tailscale-ip>:8081 and sign in with the admin user from .env (first boot performs an unattended install when those variables are set).
  9. Fix “untrusted domain” if needed: add every hostname or IP you use in the browser to NEXTCLOUD_TRUSTED_DOMAINS (space-separated), then docker compose restart app or adjust via occ as documented by Nextcloud.
  10. 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).
  11. 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.
  12. 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]

Pi hole

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
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

Commands

cd ~/homelab/pihole   # or your path
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

Commands

cd ~/homelab/nextcloud   # or your path
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

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 the db service.
  • NEXTCLOUD_ADMIN_USER and NEXTCLOUD_ADMIN_PASSWORD.
  • NEXTCLOUD_TRUSTED_DOMAINS as a space-separated list (not comma-separated). The image’s entrypoint loops over each token and runs occ 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"
Enter fullscreen mode Exit fullscreen mode

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"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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: apt upgrades for the OS; reboot when the kernel requires it.
  • Docker: docker compose pull and docker compose up -d per 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

  • [ ] .env files created from .env.example with long random passwords.
  • [ ] Tailscale ACLs reviewed: only you (or your admin group) reach admin UIs and sensitive ports.
  • [ ] NEXTCLOUD_TRUSTED_DOMAINS includes 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.


References (official docs)

Top comments (0)