I moved my Nextcloud off a rented VPS and onto a small machine at home. Files, calendar, contacts, documents and chat, on hardware I own, reachable from anywhere.
The install is the easy part. What took the time was everything around it: making the thing reachable without opening a port on my router, getting backups that genuinely restore, and a set of failures that share one shape — the service looks healthy, returns 200, and is quietly broken for one class of client.
Those failures get the most space here, because they are the part you cannot read off the official docs.
Overview
The end state:
- A machine at home running Docker. No ports forwarded on the router.
- Nextcloud, MariaDB, Redis, cron and a background worker in one Compose stack.
- Private access over Tailscale, which gives a stable hostname and a real TLS certificate without exposing anything.
- Nightly
resticbackups to a cheap VPS over SFTP, mirrored to cloud storage. - Optionally, public access through a reverse proxy on that same VPS, backhauled over the tailnet.
Throughout, box is the machine at home and cloud.example.com is the public hostname. Substitute your own.
1) The Compose stack
Five services do the work. The two people usually forget are cron and the task worker — without them background jobs never run, and Nextcloud degrades in ways that are hard to attribute later.
services:
db:
image: mariadb:10.11
container_name: nextcloud-db
restart: unless-stopped
command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ./local/db:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -uroot -p$$MYSQL_ROOT_PASSWORD -h localhost"]
interval: 15s
retries: 10
redis:
image: redis:7-alpine
container_name: nextcloud-redis
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- ./local/redis:/data
app:
image: nextcloud:32-apache
container_name: nextcloud-app
restart: unless-stopped
depends_on:
db: { condition: service_healthy }
redis: { condition: service_healthy }
ports:
- "8080:80"
environment:
MYSQL_HOST: db
REDIS_HOST: redis
PHP_UPLOAD_LIMIT: 4G
PHP_MEMORY_LIMIT: 1024M
volumes:
- ./local/nextcloud:/var/www/html
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/status.php | grep -q '\"maintenance\":false'"]
interval: 30s
start_period: 60s
cron:
image: nextcloud:32-apache
container_name: nextcloud-cron
restart: unless-stopped
entrypoint: /cron.sh
depends_on:
app: { condition: service_healthy }
volumes:
- ./local/nextcloud:/var/www/html
taskworker:
image: nextcloud:32-apache
container_name: nextcloud-taskworker
restart: unless-stopped
user: www-data
entrypoint: ["/bin/sh", "-c", "exec php /var/www/html/occ taskprocessing:worker"]
depends_on:
app: { condition: service_healthy }
volumes:
- ./local/nextcloud:/var/www/html
Two decisions worth calling out.
Bind-mount the whole install, not just data/. ./local/nextcloud:/var/www/html means config/, custom_apps/ and every user's files sit in one directory you can snapshot atomically. It makes the backup story trivial and the restore story boring, which is what you want.
Give every long-running service a healthcheck and use condition: service_healthy in depends_on. Nextcloud's installer will happily race an empty database and leave you with a half-configured instance.
2) Private access first, public later
The instinct is to forward port 443 on the router. Don't start there. Tailscale gives you a hostname, a valid certificate and access from your phone without exposing anything to the internet:
tailscale serve --bg --set-path=/nc http://127.0.0.1:8080
Nextcloud is now at https://box.tailnet-name.ts.net/nc, with a real certificate, reachable from every device on your tailnet.
Serving under a path rather than at the root needs one config value, or every generated link will be wrong:
occ config:system:set overwritewebroot --value="/nc"
occ config:system:set overwriteprotocol --value="https"
tailscale serve strips the /nc prefix before forwarding, so the container still serves at its own root. overwritewebroot is what puts the prefix back into generated URLs.
3) Moving the data
Three things move: the database, the install directory, and the config.
On the old host, take a consistent dump:
mariadb-dump -u root -p --single-transaction --routines --triggers nextcloud \
| gzip > nextcloud.sql.gz
--single-transaction matters. Without it, a dump of a live InnoDB database can capture tables from different points in time, and you will not find out until a restore behaves strangely.
Copy the install directory across, then restore the dump into the new database before starting the app container — the Nextcloud entrypoint checks for an existing installation and behaves differently if it finds one.
Then fix the things that are host-specific. config/config.php still points at the old world:
occ config:system:set trusted_domains 0 --value="box.tailnet-name.ts.net"
occ config:system:set overwrite.cli.url --value="https://box.tailnet-name.ts.net/nc"
occ maintenance:repair --include-expensive
occ files:scan --all
4) Backups that restore, not backups that run
A backup job that has never been restored is a hypothesis. The design here is deliberately dull:
- Dump the database to a staging directory.
- Copy in the Compose file and a note of the exact image tags.
resticthe install directory and the staging directory together.- Mirror the repository to a second provider.
- Verify a slice of the data.
#!/usr/bin/env bash
set -euo pipefail
INSTALL=/srv/nextcloud/local/nextcloud
STAGING=/srv/nextcloud/backup-staging
PRIMARY=sftp:vps:/home/user/nextcloud-backup
MIRROR=rclone:gdrive:nextcloud-backup
install -d -m 700 "$STAGING"
docker exec nextcloud-db sh -c \
'mariadb-dump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers nextcloud' \
| gzip > "$STAGING/nextcloud.sql.gz"
cp -f /srv/nextcloud/docker-compose.yml "$STAGING/"
{
echo "created $(date -u +%FT%TZ)"
echo "app $(docker inspect nextcloud-app --format '{{.Config.Image}}')"
echo "db $(docker inspect nextcloud-db --format '{{.Config.Image}}')"
} > "$STAGING/BACKUP-INFO.txt"
export RESTIC_REPOSITORY="$PRIMARY" RESTIC_PASSWORD_FILE=/root/.restic.pass
restic backup --tag full "$INSTALL" "$STAGING"
restic forget --tag full --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
export RESTIC_REPOSITORY="$MIRROR"
restic copy --from-repo "$PRIMARY" --from-password-file /root/.restic.pass
export RESTIC_REPOSITORY="$PRIMARY"
restic check --read-data-subset=1%
Drive it with a systemd timer rather than cron — you get logs, status and OnBootSec for a machine that is not always on:
[Unit]
Description=Nextcloud full backup
[Timer]
OnCalendar=*-*-* 04:40:00
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true is the line that matters at home. If the machine was asleep at 04:40, the job runs at the next boot instead of being silently skipped.
Two details that make restores boring rather than exciting. Record the image tags — restoring a database dumped by MariaDB 10.11 into whatever :latest resolves to next year is its own adventure. And write the restore procedure down next to the backup, not in your head.
restic check --read-data-subset=1% reads and verifies a rotating slice of the actual data. Repository metadata can be perfectly consistent while the underlying blobs are damaged.
5) Going public, if you want to
Everything above works without exposing anything. If you do want the instance reachable from outside the tailnet, the pattern that avoids opening your home router is:
- A reverse proxy on a small public VPS terminates TLS for
cloud.example.com. - It forwards to the home machine over the tailnet, not the public internet.
- The home machine still has no inbound ports open.
That is one route per service — the app, plus any companion services such as push, office or chat signalling.
6) The traps
Here is where the time went. Every one of these produced a service that returned 200 and looked healthy.
One canonical URL, and overwritehost fights you for it
Nextcloud builds absolute URLs from a single canonical host. Once the instance answers on both a tailnet name and a public name, whichever one overwritehost names wins — everywhere, including in pages served to the other audience.
The fix is to delete overwritehost entirely. Nextcloud then derives the host per request and each entry point generates its own correct links. Keep overwriteprotocol and overwritewebroot; those are genuinely global.
What that does not solve: several app settings store a single URL and cannot vary per entry point — push endpoints, office WOPI URLs, chat signalling. Those must point at whichever hostname all clients can reach, which in practice means the public one. Accept the consequence: those features then depend on the public path even from inside your own network.
trusted_proxies is silent when wrong
Behind a reverse proxy, every request appears to originate from the proxy unless it is in trusted_proxies. Nothing breaks visibly. Rate limiting and brute-force protection simply count every attacker on the internet as one client.
It is an array, so append rather than overwrite — clobbering index 0 is an easy way to remove the loopback entry your existing local proxy depends on.
Docker rewrites resolv.conf even with host networking
A container using network_mode: host does not inherit the host's DNS. Docker still supplies its own /etc/resolv.conf, so Tailscale's MagicDNS names are invisible inside the container even though the host resolves them fine.
The symptom is a container that cannot resolve a hostname the host resolves instantly. The fix is an explicit extra_hosts entry pinning the name to the tailnet IP.
Mail: the relay hostname must match the certificate
Nextcloud with no mail configuration does not fail loudly. It falls back to SMTP on 127.0.0.1:25 inside the container, where nothing is listening, and every outbound message dies with Connection refused. Invitations, password resets, share notices — all silently gone.
Two things to get right. Use your mail provider's real server hostname, not a friendly alias pointing at the same IP: shared hosting commonly serves a wildcard certificate for the provider's own domain, so smtp.yourdomain.com resolves fine and then fails hostname verification.
And check how your version reads mail_smtpsecure. In current Nextcloud only the literal value ssl enables implicit TLS; anything else, including tls, falls through to opportunistic STARTTLS. If you want enforced TLS, use port 465 with ssl.
The .well-known redirect that breaks iOS
Adding the calendar to an iPhone failed with "Cannot connect using SSL", on an instance with a valid certificate, TLS 1.3, and a browser that was perfectly happy.
Nextcloud's .htaccess redirects /.well-known/caldav to a relative target. Apache expands that using the connection scheme — which behind a TLS-terminating proxy is plain HTTP. So the redirect came back as http://, and iOS refuses to follow a downgrade.
Things that did not fix it: SetEnvIf X-Forwarded-Proto https HTTPS=on (only changes what PHP sees, not how Apache builds redirects), Header edit Location in either form, and a server-scope RewriteRule (mod_rewrite rules in conf-enabled are not inherited into the image's VirtualHost, although mod_headers directives are — an asymmetry that makes this genuinely confusing to debug).
What worked:
<Directory /var/www/html>
RewriteEngine On
RewriteOptions InheritDownBefore
RewriteRule ^\.well-known/carddav/?$ https://%{HTTP_HOST}/nc/remote.php/dav/ [R=301,L]
RewriteRule ^\.well-known/caldav/?$ https://%{HTTP_HOST}/nc/remote.php/dav/ [R=301,L]
</Directory>
RewriteOptions InheritDownBefore is the load-bearing line: per-directory rules in .htaccess otherwise replace parent rules instead of running after them.
Relayed media and the exit-node hairpin
If you run video calls and route the home machine's outbound traffic through a VPN or Tailscale exit node, be careful pointing services at their own public IP. Traffic hairpins out and back. TCP survives it. UDP does not — a STUN binding request gets a reply while a TURN allocation fails, which is a maddening combination to debug.
Point internal components at the tailnet address instead, and configure the relay to listen on both interfaces while allocating only on the public one.
What this actually buys
Files, calendar and contacts sync to every device. Documents edit in the browser. Nothing is metered, and the storage ceiling is a disk I can replace.
The honest cost is that I am now the operator. When the machine at home is down, it is down. Backups are only as good as the last restore I actually tested. And routing public traffic through a small VPS means that VPS is load-bearing — worth giving it swap before you find out the hard way.
If you take one thing from this: the failures that cost the most were not crashes. They were services returning 200 while being unusable for one specific client — an iPhone, an external guest, a mail server. Test each entry point separately, from a device that actually uses it.