This repository documents my Raspberry Pi home server setup, including Docker Compose files, service configurations, backup scripts, and notes from my own deployment.
The goal of this project is to keep everything self-hosted, easy to rebuild, and simple to maintain.
| Service | Purpose | Repo |
|---|---|---|
| Nginx Proxy Manager | Reverse proxy with SSL support | ↗︎ |
| WireGuard | Secure remote access to the LAN | ↗︎ |
| Gluetun | VPN routing for selected containers | ↗︎ |
| OpenSpeedTest | Local network speed testing | ↗︎ |
| UniFi Network Application | UniFi controller | ↗︎ |
| Service | Purpose | Repo |
|---|---|---|
| AdGuard Home | DNS-level ad and telemetry blocking | ↗︎ |
| Vaultwarden | Lightweight self-hosted password manager | ↗︎ |
| Service | Purpose | Repo |
|---|---|---|
| Overseerr | Media request management | ↗︎ |
| Radarr | Movie automation | ↗︎ |
| Sonarr | TV show automation | ↗︎ |
| Prowlarr | Indexer management | ↗︎ |
| Flaresolverr | Cloudflare challenge handling | ↗︎ |
| qBittorrent | Torrent client | ↗︎ |
| Service | Purpose | Repo |
|---|---|---|
| Portainer | Docker management UI | ↗︎ |
| Watchtower | Automatic container updates | ↗︎ |
| Filebrowser | Web-based file manager | ↗︎ |
| Homepage | Service dashboard | ↗︎ |
| Service | Purpose | Repo |
|---|---|---|
| Obsidian LiveSync | Encrypted note syncing | ↗︎ |
| Script | Purpose |
|---|---|
| cpu_temp.sh | Sends Discord alerts if CPU temperature gets too high |
| backup-obsidian.sh | Backs up Obsidian LiveSync data to NAS |
| docker-backup.sh | Pushes Docker configs to GitHub |
| unattended-upgrades | Automated OS update |
| NAS Mount | Mount NAS on RPI startup |
I use Raspberry Pi OS 64-bit based on Debian, but most Linux distributions should work with small adjustments.
Docker allows applications to run inside lightweight containers, making deployments easier to manage and rebuild.
Docker Compose lets you manage multiple containers using a single YAML file.
Installation steps vary depending on your OS. Refer to Docker’s official documentation for detailed instructions.
curl -sSl https://get.docker.com | sh
To avoid running Docker as root:
sudo usermod -aG docker ${USER}
Log out and back in for the group change to apply.
Verify Docker:
docker run hello-world
Install Docker Compose:
sudo apt install docker-compose-plugin
Verify Docker Compose:
docker compose version
I keep each container or stack inside its own directory:
~/containers/nginx-proxy-manager
~/containers/adguard
~/containers/arr-stack
Each directory contains its own docker-compose.yml file.
To start a stack:
docker compose up -d
Nginx Proxy Manager makes it easy to manage reverse proxies and SSL certificates through a web UI.
Example:
Instead of opening services using IP addresses like:
http://192.168.1.10:3000
You can use internal domains such as:
portainer.lan
adguard.lan
I followed Wolfgang’s Channel on YouTube for the initial setup using deSEC DNS. video
services:
nginx_proxy_manager:
image: jc21/nginx-proxy-manager:latest
container_name: nginx_proxy_manager
ports:
- "80:80"
- "81:81"
- "443:443"
volumes:
- ./config.json:/app/config/production.json
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- proxy
restart: unless-stopped
networks:
proxy:
name: proxy
Access the web UI:
http://<raspberrypi-ip>:81
AdGuard Home acts as a DNS sinkhole, blocking ads, trackers, and telemetry requests across your network.
services:
adguardhome:
image: adguard/adguardhome:latest
container_name: adguard
ports:
- "53:53/tcp"
- "53:53/udp"
# - "67:67/udp"
# - "68:68/udp"
- "3003:3000/tcp"
- "8062:80/tcp"
- "445:443"
- "853:853/tcp"
- "5443:5443"
volumes:
- ./work:/opt/adguardhome/work
- ./conf:/opt/adguardhome/conf
restart: unless-stopped
networks:
- adguard
- proxy
networks:
adguard:
name: adguard
proxy:
external: true
Initial setup:
http://<raspberrypi-ip>:3003
Main interface after setup:
http://<raspberrypi-ip>:8062
I use DNS rewrites to point local domains such as *.lan to my reverse proxy.
Portainer provides a simple web UI for managing Docker containers, images, networks, and volumes.
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
volumes:
- data:/data
- /var/run/docker.sock:/var/run/docker.sock
ports:
- "9443:9443"
networks:
- portainer
- proxy
restart: unless-stopped
volumes:
data:
networks:
portainer:
name: portainer
proxy:
external: true
Access:
https://<raspberrypi-ip>:9443
Vaultwarden is a lightweight self-hosted password manager, compatible with the Bitwarden apps and browser extensions.
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: bitwarden
environment:
- WEBSOCKET_ENABLED=true
volumes:
- ./data:/data
ports:
- "8085:80"
restart: unless-stopped
networks:
- bitwarden
- proxy
networks:
bitwarden:
name: bitwarden
internal: true
proxy:
external: true
Vaultwarden should be exposed through HTTPS, otherwise some Bitwarden features may not work correctly.
I handle this using Nginx Proxy Manager.
WireGuard gives secure remote access to my LAN and internal services while away from home.
services:
wireguard:
image: linuxserver/wireguard
container_name: wireguard
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Stockholm
- SERVERURL=auto
- SERVERPORT=51820
- PEERS=3
- PEERDNS=192.168.1.10
- INTERNAL_SUBNET=10.13.13.0
- ALLOWEDIPS=0.0.0.0/0
volumes:
- ./config:/config
- /lib/modules:/lib/modules
ports:
- "51820:51820/udp"
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
- net.ipv4.ip_forward=1
restart: unless-stopped
networks:
- wireguard
networks:
wireguard:
name: wireguard
After setup, generate a client QR code with:
docker exec -it wireguard /app/show-peer <peer-name>
You can scan the QR code directly in the WireGuard mobile app.
Watchtower automatically checks for updated container images and redeploys containers.
services:
watchtower:
image: nickfedor/watchtower:latest
container_name: watchtower
environment:
TZ: Europe/Stockholm
WATCHTOWER_SCHEDULE: "0 0 0 * * 0"
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_RESTARTING: "true"
WATCHTOWER_DISABLE_CONTAINERS: watchtower,gluetun
ports:
- "86:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:
- watchtower
restart: unless-stopped
networks:
watchtower:
name: watchtower
I had issues letting watchtower update itself or gluetun so watchtower ignores those two containers.
The docker compose above runs updates weekly.
Filebrowser is a lightweight web-based file manager.
services:
filebrowser:
image: hurlenko/filebrowser
container_name: filebrowser
environment:
- FB_BASE_URL=/filebrowser
volumes:
- ./DATA_DIR:/data
- ./CONFIG_DIR:/config
ports:
- "444:8080"
networks:
- filebrowser
- proxy
restart: unless-stopped
networks:
filebrowser:
name: filebrowser
proxy:
external: true
If a default password is generated, you can view it with:
docker logs -f filebrowser
Obsidian LiveSync allows encrypted syncing between devices using CouchDB for obsidian note taking.
services:
couchdb-obsidian-livesync:
image: couchdb:latest
container_name: obsidian-livesync
environment:
- TZ=Europe/Stockholm
- COUCHDB_USER=admin
- COUCHDB_PASSWORD=/run/secrets/couchdb_password
secrets:
- couchdb_password
volumes:
- ./data:/opt/couchdb/data
- ./etc:/opt/couchdb/etc/local.d
ports:
- "5984:5984"
networks:
- obsidian
restart: unless-stopped
networks:
obsidian:
name: obsidian
secrets:
couchdb_password:
file: ./secrets/couchdb_password.txt
I recommend following the dedicated setup guide for configuring the plugin and database made by Timely_Anteater_9330. Guide
Gluetun routes selected containers through a VPN tunnel.
I mainly use it for torrent-related containers.
services:
gluetun:
image: qmcgaw/gluetun:latest
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
secrets:
- vpn_user
- vpn_password
volumes:
- ./data:/gluetun
environment:
- TZ=Europe/Stockholm
- VPN_SERVICE_PROVIDER=
- VPN_TYPE=
- OPENVPN_USER_FILE=/run/secrets/vpn_user
- OPENVPN_PASSWORD_FILE=/run/secrets/vpn_password
- SERVER_REGIONS=Switzerland
- FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24
ports:
- "8081:8081"
- "6881:6881"
- "6881:6881/udp"
- "9696:9696"
- "8191:8191"
- "8000:8000"
restart: unless-stopped
networks:
- gluetun
networks:
gluetun:
name: gluetun
secrets:
vpn_user:
file: ./secrets/vpn_user.txt
vpn_password:
file: ./secrets/vpn_password.txt
Containers using:
network_mode: "container:gluetun"
share the same VPN connection.
The *arr stack automates downloading, organizing, and managing media.
My setup uses:
I run these in a single Docker Compose stack because they depend heavily on each other.
All containers reference the same root mount (/mnt/nas) so hardlinking works correctly.
Example structure:
nas
├── downloads
│ ├── radarr
│ └── tv-sonarr
├── movies
└── tv-shows
Hardlinks let media managers move files instantly without duplicating storage usage.
You can verify hardlinks with:
ls -l
or compare inode numbers:
stat filename.mkv
If both files share the same inode, hardlinking works correctly.
services:
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
environment:
- PUID=1000
- PGID=1000
- UMASK=022
- TZ=Europe/Stockholm
volumes:
- radarr_config:/config
- /mnt/nas:/nas
ports:
- "7878:7878"
networks:
- arr
- proxy
restart: unless-stopped
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
environment:
- PUID=1000
- PGID=1000
- UMASK=022
- TZ=Europe/Stockholm
volumes:
- sonarr_config:/config
- /mnt/nas:/nas
ports:
- "8989:8989"
networks:
- arr
- proxy
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=1000
- PGID=1000
- UMASK=022
- TZ=Europe/Stockholm
- WEBUI_PORT=8081
- TORRENTING_PORT=6881
volumes:
- qbittorrent_config:/config
- /mnt/nas/downloads:/nas/downloads
network_mode: "container:gluetun"
restart: always
seerr:
image: ghcr.io/seerr-team/seerr:latest
container_name: seerr
environment:
- LOG_LEVEL=debug
- TZ=Europe/Stockholm
volumes:
- seerr_config:/app/config
ports:
- "5055:5055"
dns:
- 1.1.1.1
- 8.8.8.8
networks:
- arr
- proxy
restart: unless-stopped
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
environment:
- PUID=1000
- PGID=1000
- UMASK=022
- TZ=Europe/Stockholm
volumes:
- prowlarr_config:/config
network_mode: "container:gluetun"
restart: always
flaresolverr:
image: flaresolverr/flaresolverr:latest
container_name: flaresolverr
environment:
- LOG_LEVEL=${LOG_LEVEL:-info}
- LOG_HTML=${LOG_HTML:-false}
- CAPTCHA_SOLVER=${CAPTCHA_SOLVER:-none}
- TZ=Europe/Stockholm
volumes:
- flaresolverr_config:/config
network_mode: "container:gluetun"
restart: always
networks:
arr:
name: arr
proxy:
external: true
volumes:
radarr_config:
sonarr_config:
seerr_config:
prowlarr_config:
flaresolverr_config:
qbittorrent_config:
services:
speedtest:
image: openspeedtest/latest
container_name: openspeedtest
restart: unless-stopped
ports:
- "3002:3002"
- "3001:3001"
networks:
- proxy
networks:
proxy:
external: true
services:
unifi-db:
image: mongo:4.4.18
container_name: unifi-db
network_mode: host
environment:
- MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
- MONGO_USER=${MONGO_USER}
- MONGO_PASS=${MONGO_PASS}
- MONGO_DBNAME=${MONGO_DBNAME}
- MONGO_AUTHSOURCE=${MONGO_AUTHSOURCE}
volumes:
- ./mongo/data:/data/db
- ./init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro
restart: unless-stopped
unifi-network-application:
image: lscr.io/linuxserver/unifi-network-application:latest
container_name: unifi
network_mode: host
depends_on:
- unifi-db
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- MONGO_USER=${MONGO_USER}
- MONGO_PASS=${MONGO_PASS}
- MONGO_HOST=127.0.0.1
- MONGO_PORT=27017
- MONGO_DBNAME=${MONGO_DBNAME}
- MONGO_AUTHSOURCE=${MONGO_AUTHSOURCE}
volumes:
- ./unifi:/config
restart: unless-stopped
init-mongo.sh
#!/bin/bash
if which mongosh > /dev/null 2>&1; then
mongo_init_bin='mongosh'
else
mongo_init_bin='mongo'
fi
"${mongo_init_bin}" <<EOF
use ${MONGO_AUTHSOURCE}
db.auth("${MONGO_INITDB_ROOT_USERNAME}", "${MONGO_INITDB_ROOT_PASSWORD}")
db.createUser({
user: "${MONGO_USER}",
pwd: "${MONGO_PASS}",
roles: [
"clusterMonitor",
{ db: "${MONGO_DBNAME}", role: "dbOwner" },
{ db: "${MONGO_DBNAME}_stat", role: "dbOwner" },
{ db: "${MONGO_DBNAME}_audit", role: "dbOwner" },
{ db: "${MONGO_DBNAME}_restore", role: "dbOwner" }
]
})
EOF
.env
# ===== System =====
PUID=1000
PGID=1000
TZ=TZ
# ===== MongoDB root =====
MONGO_INITDB_ROOT_USERNAME=root
MONGO_INITDB_ROOT_PASSWORD=<strong password>
# ===== UniFi Mongo user =====
MONGO_USER=unifi
MONGO_PASS=<strong password>
MONGO_DBNAME=unifi
MONGO_AUTHSOURCE=admin
# ===== Mongo connection =====
MONGO_HOST=unifi-db
MONGO_PORT=27017
.env
I use a simple yaml script with Discord webhooks to notify me if the Raspberry Pi overheats.
The script:
I run the script using a systemd timer every 10 minutes.
#!/bin/yaml
BASE_TEMP=60
STEP=5
STATE_FILE="/tmp/pi_temp_alert.state"
# get CPU temperature in Celsius (rounded down)
pi_temp=$(vcgencmd measure_temp | awk -F "[=.'']" '{print int($2)}')
this_pi=$(hostname)
discord_pi_webhook=
# If below base temp → reset state
if [[ "$pi_temp" -lt "$BASE_TEMP" ]]; then
rm -f "$STATE_FILE"
exit 0
fi
# First alert at or above BASE_TEMP
if [[ ! -f "$STATE_FILE" ]]; then
echo "$pi_temp" > "$STATE_FILE"
curl -H "Content-Type: application/json" -X POST \
-d "{\"content\":\"🚨 ALERT! ${this_pi} CPU temp reached ${pi_temp}°C\"}" \
"$discord_pi_webhook"
exit 0
fi
last_alert_temp=$(cat "$STATE_FILE")
temp_diff=$((pi_temp - last_alert_temp))
# Alert only if temp increased by STEP
if [[ "$temp_diff" -ge "$STEP" ]]; then
echo "$pi_temp" > "$STATE_FILE"
curl -H "Content-Type: application/json" -X POST \
-d "{\"content\":\"⚠️ UPDATE! ${this_pi} CPU temp increased to ${pi_temp}°C\"}" \
"$discord_pi_webhook"
fi
This script:
rsyncBackups are stored on my NAS.
#!/bin/yaml
# ── Config ────────────────────────────────────────────────────────────────────
COMPOSE_DIR="/home/admin/containers/obsidian-livesync"
BACKUP_ROOT="/mnt/nas/backups/obsidian-livesync"
RETENTION_DAYS=30
LOG_FILE="/var/log/obsidian-backup.log"
# ─────────────────────────────────────────────────────────────────────────────
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M")
BACKUP_DIR="$BACKUP_ROOT/$TIMESTAMP"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
log "──────────────────────────────────────────"
log "Starting obsidian-livesync backup"
# Stop the container
log "Stopping container..."
docker compose -f "$COMPOSE_DIR/docker-compose.yml" stop
if [ $? -ne 0 ]; then
log "ERROR: Failed to stop container. Aborting backup."
exit 1
fi
# Sync data and config
log "Rsyncing to $BACKUP_DIR ..."
mkdir -p "$BACKUP_DIR/data" "$BACKUP_DIR/etc"
rsync -a --delete "$COMPOSE_DIR/data/" "$BACKUP_DIR/data/"
DATA_STATUS=$?
rsync -a --delete "$COMPOSE_DIR/etc/" "$BACKUP_DIR/etc/"
ETC_STATUS=$?
RSYNC_STATUS=$(( DATA_STATUS + ETC_STATUS ))
# Always restart the container, even if rsync failed
log "Restarting container..."
docker compose -f "$COMPOSE_DIR/docker-compose.yml" start
if [ $RSYNC_STATUS -ne 0 ]; then
log "ERROR: rsync failed with exit code $RSYNC_STATUS."
exit 1
fi
log "Backup complete → $BACKUP_DIR"
# Prune backups older than retention period
log "Pruning backups older than $RETENTION_DAYS days..."
find "$BACKUP_ROOT" -maxdepth 1 -type d -mtime +$RETENTION_DAYS -exec rm -rf {} +
log "Done."
I use GitHub as an off-site backup for my Docker Compose files.
The script:
Sensitive files are excluded using .gitignore.
#!/bin/yaml
cd ~/containers
git add -A
git diff --cached --quiet && echo "No changes, skipping commit." && exit 0
git commit -m "Auto backup $(date '+%Y-%m-%d %H:%M')"
git push origin main
Example .gitignore
# Ignore everything by default
*
# Allow folders
!*/
# Allow Docker Compose files
!*/docker-compose.yml
!*/docker-compose.yaml
# Ignore secrets and environment files
*.env
*.txt
# Ignore persistent application data
*/letsencrypt/*
*/DATA_DIR/*
*/data
*/work
# Ignore additional YAML configs
*.yaml
# Keep root files
!.gitignore
sudo apt install unattended-upgrades
Se for more info: https://wiki.debian.org/PeriodicUpdates
Example /etc/fstab entry for mounting a Synology NAS using NFS:
192.168.1.50:/volume1/nas /mnt/nas nfs defaults,nofail,_netdev,x-systemd.automount,x-systemd.device-timeout=30 0 0
This setup has changed a lot over time and will probably continue evolving.
The repository mainly exists as:
Feel free to adapt any part of it for your own setup.