2024-07-24
Setting Up Nextcloud on a Raspberry Pi: Docker Compose, PostgreSQL, Traefik and Push Notifications
Setting Up Nextcloud on a Raspberry Pi: Docker Compose, PostgreSQL, Traefik and Push Notifications
Update (July 2026): two years later this stack looks quite different - the Traefik + port-forwarding edge described here was eventually replaced by a Cloudflare Tunnel with zero open ports. This post stays up as the record of the original build; the follow-up is From Port Forwarding to Cloudflare Tunnels.
A few days ago I wrote about why you might want a home server. This is the first concrete build on mine: Nextcloud on a Raspberry Pi, fully containerized, with a proper database, caching, TLS certificates that renew themselves, and the one thing most Docker setups skip - working push notifications for the clients.
Credit where it's due: two community guides shaped this setup - this Raspberry Pi guide on the Nextcloud forum, and wwe's excellent docker compose with notify_push walkthrough. What follows is my adaptation of both for an ARM64 Pi, plus the details that cost me evenings to figure out.
The stack at a glance
One Compose project, six containers, one job each:
| Container | Role |
|---|---|
app |
Nextcloud itself (Apache image) - PHP, WebDAV, the works |
cron |
Same image, different entrypoint - background jobs every 5 minutes |
notify_push |
The Client Push daemon - real-time sync without polling |
db |
PostgreSQL 15 - faster than MariaDB in my testing, and cleaner upgrades |
redis |
File locking, caching, and PHP sessions |
imaginary |
Preview/thumbnail generation, so PHP doesn't choke on photos |
In front of all of it sits Traefik v3, terminating TLS with Let's Encrypt certificates. The router forwards ports 80 and 443 to the Pi; a Cloudflare A record points the domain at my (dynamic, sigh) home IP.
The interesting parts of the Compose file
The full file is long, so here are the pieces that carry the design. Postgres and the admin account get their credentials from Docker secrets - chmod 600 files on disk, never environment variables:
db:
image: postgres:15
restart: unless-stopped
volumes:
- ./db:/var/lib/postgresql/data
environment:
- POSTGRES_DB_FILE=/run/secrets/postgres_db
- POSTGRES_USER_FILE=/run/secrets/postgres_user
- POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $(cat /run/secrets/postgres_db) -U $(cat /run/secrets/postgres_user)"]
start_period: 15s
interval: 30s
secrets:
- postgres_db
- postgres_password
- postgres_user
The healthchecks aren't decoration: the app container declares depends_on with condition: service_healthy, so Nextcloud never starts against a database that isn't actually accepting connections yet. That single line eliminates the classic "Internal Server Error on first boot" that fills the forum threads.
The app and cron containers share the same bind mounts (./nextcloud, ./config, ./apps, ./data), so a single image upgrade moves both in lockstep.
notify_push, the part worth stealing
Without Client Push, every Nextcloud client polls the server on a timer - slow to react and wasteful on a Pi. The notify_push app ships a small Rust daemon that holds a websocket open to every client and tells them the instant something changes. Getting it running in Compose is the part most guides skip, and it's exactly what wwe's thread solves:
notify_push:
image: nextcloud:${NEXTCLOUD_VERSION}
restart: unless-stopped
depends_on:
- app
environment:
- PORT=7070
- NEXTCLOUD_URL=http://app # talk to Nextcloud directly, not through the proxy
entrypoint: /var/www/html/custom_apps/notify_push/bin/aarch64/notify_push /var/www/html/config/config.php
volumes:
- ./apps:/var/www/html/custom_apps
- ./config:/var/www/html/config
Three details matter here:
- The binary path is architecture-specific. The app ships prebuilt binaries and on a Pi it's
bin/aarch64/notify_push- thex86_64path from most examples fails with a cryptic "no such file" inside a container that plainly has files. NEXTCLOUD_URL=http://apppoints the daemon at the app container directly over the Docker network. Sending it through the public URL would add TLS + proxy hops for a loopback conversation.- It will crash-loop on first boot, and that's fine. The binary doesn't exist until the app is installed inside Nextcloud. The sequence is: bring the stack up, install the app, restart the service, then wire it up:
docker compose exec app php occ app:install notify_push
docker compose up -d notify_push
docker compose exec app php occ notify_push:setup https://nextcloud.example.com/push
The routing is the subtle bit: clients reach the daemon on the same domain as Nextcloud, under /push. Traefik matches PathPrefix(/push) with a higher router priority than the main catch-all, strips the prefix with a middleware, and forwards to port 7070. Get the priority wrong and the main router swallows /push requests - nothing errors, push just silently doesn't work:
# traefik dynamic config
http:
middlewares:
nextcloud-redirect:
redirectScheme:
scheme: https
permanent: true
secHeaders:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
nextcloud_striprefix_push:
stripPrefix:
prefixes: ["/push"]
occ notify_push:self-test confirms the full chain; all checks green.
Small files, big debugging hours
- PHP sessions belong in Redis. The official image stores sessions on disk, which fights with multiple containers sharing the volume. A one-file fix mounted into the PHP config directory (context) points session storage at the
redisservice - and logins stop mysteriously expiring. - Apache needs
remoteiptold about the proxy. Behind Traefik, every request appears to come from the Docker bridge. A smallremoteip.confmounted into the container restores real client IPs - without it, brute-force protection throttles the proxy instead of the attacker. - HSTS and the https redirect live in Traefik, not Nextcloud - the
secHeadersandredirectSchememiddlewares above. Nextcloud's own security scan goes green once they're in place. - First boot takes minutes. The container reports "started" long before initialization finishes. Patience beats re-creating the stack because the login page isn't up after thirty seconds.
The dynamic IP problem (a preview)
The one wart on this setup: my ISP rotates my public IP, and the Cloudflare A record has to follow it. For now a small script on a systemd timer checks the public IP daily, updates the DNS record through the Cloudflare API when it drifts, and resets Traefik's ACME storage so a fresh certificate gets issued. It works, but it's the part of the architecture I trust the least - the original guide teases a Cloudflare Zero Trust integration, and I suspect that's the direction this server eventually heads.
For now though: files sync, calendars share, photos upload from the phone, previews render fast thanks to imaginary, and the desktop client updates the instant a file changes anywhere. For a credit-card-sized computer in a drawer, that's a very complete personal cloud.