Nextcloud
Nextcloud holds documents, calendars and contacts, and it syncs them to desktops and phones. It is the stack in this Blueprint with the most moving parts: an application container, a separate web server, a database, a cache, and a background worker.
Last run on a host 9 Nov 2026 · data restored from a backup
Pinned at 35.0.2-fpm-alpine (released 6 Nov 2026) · the newest upstream release
In the upstream images: 1 CRITICAL · 28 HIGH with a published fix — per image
To try it: Docker, nothing else. See Try it locally.
To deploy it behind the proxy: Traefik running, a domain, and SMTP credentials.
Try it locally
Section titled “Try it locally”On your own machine, with no proxy, domain or certificate. From apps/nextcloud/:
cp .env.local.example .env.local
# The secrets ship empty; generate the ones this stack needs{ echo "DB_PASSWORD=$(openssl rand -base64 32 | tr -d '\n')" echo "DB_ROOT_PASSWORD=$(openssl rand -base64 32 | tr -d '\n')" echo "REDIS_PASSWORD=$(openssl rand -hex 32)" echo "ADMIN_PASSWORD=$(openssl rand -base64 24 | tr -d '\n')"} >> .env.local
mkdir -p volumes/local/mysql volumes/local/redis volumes/local/htmlsudo chown 999:999 volumes/local/mysql
docker compose -f docker-compose.local.yml --env-file .env.local up -dThen open http://localhost:8080 and log in as admin with the
ADMIN_PASSWORD from .env.local. Stop it again with the same command and
down.
Installation
Section titled “Installation”There is no setup wizard. The stack installs itself from environment variables and secret files — repeatable, reviewable, and with no unauthenticated setup form reachable in between.
From apps/nextcloud/:
-
Terminal window cp .env.example .env -
Nextcloud has to be told which address Traefik talks to it from, or it will think every visitor is Traefik. Print it:
Terminal window docker network inspect nextcloud-edge \--format '{{range .IPAM.Config}}{{.Subnet}} {{end}}'nextcloud-edgeis the stack’s own edge network (TRAEFIK_NETWORK), which Traefik joins onceEDGE_NETWORKSincore/traefik/.envlists it. Copy whatever that prints intoNC_TRUSTED_PROXIES— all of it. If you enabled IPv6 there will be two entries, and leaving one out means everyone shares a single address as far as the login protection is concerned: one person mistyping a password locks out everybody.APP_TRAEFIK_HOST=cloud.example.comNC_TRUSTED_PROXIES=172.19.0.0/16TZ=Europe/Vienna -
Set mail now rather than afterwards — Nextcloud cannot reset a password without it. Use port 465 — it is encrypted from the first byte, where port 587 starts unencrypted and switches over partway:
SMTP_HOST=smtp.example.comSMTP_PORT=465SMTP_SECURE=sslSMTP_NAME=your-smtp-loginMAIL_FROM_ADDRESS=nextcloudMAIL_DOMAIN=example.comNote that
MAIL_FROM_ADDRESSis only the part before the@. Together withMAIL_DOMAINabove it makes[email protected], which has to be an address your mail provider has verified for sending.Many hosting companies block port 465 outbound. If yours does, use
SMTP_PORT=587withSMTP_SECURE=tls— the weaker of the two, and it works. -
Create the secrets. All six before the first start:
Terminal window mkdir -p .secretsopenssl rand -base64 32 | tr -d '\n' > .secrets/db_pwd.txtopenssl rand -base64 32 | tr -d '\n' > .secrets/db_root_pwd.txtopenssl rand -hex 32 | tr -d '\n' > .secrets/redis_pwd.txtprintf 'admin' > .secrets/admin_user.txtopenssl rand -base64 24 | tr -d '\n' > .secrets/admin_pwd.txtprintf 'your-smtp-key' > .secrets/smtp_pwd.txtchmod 600 .secrets/*.txtTwo of the six are read by Nextcloud itself while it runs, so it needs permission for those:
Terminal window sudo chown "$USER":82 .secrets/redis_pwd.txt .secrets/smtp_pwd.txtchmod 640 .secrets/redis_pwd.txt .secrets/smtp_pwd.txtThis keeps the files editable for you as well, which matters the day you change a password.
82is the user Nextcloud runs as in these images; some variants use33, whichdocker compose exec -u root app id www-datareports. -
Read the administrator password once and store it in your password manager — it is generated, never displayed:
Terminal window cat .secrets/admin_pwd.txt -
Start, and watch the install run:
Terminal window docker compose up -ddocker compose logs -f appExpect
Initializing nextcloudfollowed byNextcloud was successfully installed. The database has a health check the application waits on, so the first start takes a minute.
Verify
Section titled “Verify”docker compose psdocker compose exec -u www-data app php occ statusdocker compose exec -u www-data app php occ config:system:get trusted_proxiesExpected: every container healthy, installed: true, and your proxy subnets
listed.
Then log in and open /settings/admin/overview. That page is the acceptance
test — it runs the project’s own checks against the running instance and it is
more thorough than anything written here. Work until it reports no warnings.
Send a test message before handing the instance to anyone:
docker compose exec -u www-data app php occ user:welcome adminHarden before the first user
Section titled “Harden before the first user”Run these once. Each is occ config:system:set, so re-running is harmless.
-
Set the region, so phone numbers in contacts are parsed:
Terminal window docker compose exec -u www-data app php occ \config:system:set default_phone_region --value="AT" -
Move background jobs to a quiet hour — the value is an hour, UTC:
Terminal window docker compose exec -u www-data app php occ \config:system:set maintenance_window_start --value=1 --type=integer -
Stop new accounts being seeded with example files:
Terminal window docker compose exec -u www-data app php occ \config:system:set skeletondirectory --value="" -
Bound preview generation. Thumbnails are produced by PHP workers; without a limit, one large image occupies a worker long enough for users to notice:
Terminal window docker compose exec -u www-data app php occ \config:system:set preview_max_x --value=2048 --type=integerdocker compose exec -u www-data app php occ \config:system:set preview_max_y --value=2048 --type=integerdocker compose exec -u www-data app php occ \config:system:set preview_max_filesize_image --value=25 --type=integer -
Restrict the administration settings to the networks you administer from. Everything else stays reachable; only
/settings/adminnarrows:Terminal window docker compose exec -u www-data app php occ \config:system:set allowed_admin_ranges 0 --value="100.64.0.0/10"docker compose exec -u www-data app php occ \config:system:set allowed_admin_ranges 1 --value="fd7a:115c:a1e0::/48"Those two ranges are Tailscale’s. Substitute your own, and confirm you can still reach the page from where you work before you rely on it.
Going further
Section titled “Going further”Opening it up
Section titled “Opening it up”The stack ships APP_TRAEFIK_ACCESS=access-trusted-vpn — reachable from the ranges you declare
and the VPN. No closed policy admits a Docker network, so the instance’s own
setup checks, which call its public name, report failures until the route is
opened; nothing of its function depends on them. Switch to access-public once it
is configured, verified, and you have logged in successfully. That is a deliberate
change to your attack surface.
An office server — OnlyOffice, Euro-Office, Collabora — calls Nextcloud back past
the closed route: the opt-in office.yml joins Nextcloud’s nginx, and only nginx,
to the office stack’s edge network (OFFICE_NETWORK), and the connector hands out
nginx as its internal address. The settings — StorageUrl and jwt_secret for
OnlyOffice and Euro-Office, richdocuments:activate-config for Collabora — are in
the stack README, “Office servers behind a closed route”. Collabora additionally
needs office-collabora.yml in COMPOSE_FILE, which lets Nextcloud reach
Collabora’s discovery and /cool/convert-to through nginx for previews, and
NC_OVERWRITECONDADDR in .env, which limits Nextcloud’s URL overwrites to
requests from Traefik so Collabora’s template fetch gets internal URLs. With
those, documents in all three open, save and reopen behind the closed route, and
Collabora’s public route still refuses /cool/convert-to.
Rotating a key
Section titled “Rotating a key”Most editors save by writing a new file and renaming it over the old one. The
container’s mount still points at the file that was replaced, so it keeps reading
the previous value and restart does not help:
docker compose up -d --force-recreate app cronWriting in place — printf '%s' "$KEY" > .secrets/smtp_pwd.txt — avoids this.
Sizing the host
Section titled “Sizing the host”Nextcloud states its requirements per PHP process — 128 MB minimum, 512 MB recommended, 256 MB for the built-in updater (system requirements). For a small team with this stack alone on the host:
| Minimum | Comfortable | |
|---|---|---|
| CPU | 2 cores | 4 cores |
| RAM | 4 GB | 8 GB |
| Disk | 20 GB plus your data | SSD, 20 GB plus data |
The 4 GB floor is arithmetic: MariaDB with the buffer pool this stack configures takes about 1.5 GB, Redis 0.5 GB, the OS and nginx 0.5 GB, and the rest holds the PHP workers. Below it, cron and interactive requests compete for the pool and answer 502. An office server on the same host adds about 1.5 GB of its own.
A lean application set
Section titled “A lean application set”For document collaboration, keep Files, Group Folders, Guests, Activity and the office connector. Photos and Recommendations run background machine-learning jobs; Survey Client and Support phone home; Weather Status calls an external API from the server; First Run Wizard is done after setup; Contacts is only needed when Nextcloud is also the address book. Disabling what you do not use shortens cron runs and keeps the PHP worker pool predictable.
Backup
Section titled “Backup”This section covers what Borgmatic needs to know about Nextcloud. Installing it, the repository, the encryption keys, the schedule and the restore mechanics are all on Backup and restore — set that up first, then come back here.
Nextcloud keeps its file index in the database and the files on disk. Capture the
two at different moments under load and the restore shows files the index does
not know about, or index entries pointing at nothing. occ files:scan repairs the
first case and not the second. This is the stack in the Blueprint where quiescing
genuinely matters.
Three things must be captured together — a database dump alone restores an instance that cannot read its own files:
| Where | |
|---|---|
| Database | dumped through Borgmatic’s hook, not copied |
Data directory and config/config.php |
the nextcloud_html volume |
.env and .secrets/ |
the deployment directory |
config.php carries the instance secret and the password salt. Restoring the
database against a regenerated config invalidates sessions and can make encrypted
content unreadable.
What to add to /etc/borgmatic/config.yaml
Section titled “What to add to /etc/borgmatic/config.yaml”-
The data. Deliberately not the database volume — the dump below is the consistent copy, the raw files would be a torn one:
source_directories:- /srv/docker- /var/lib/docker/volumes/nextcloud_nextcloud_html/_dataConfirm the volume’s real name with
docker volume ls; it is prefixed with your Compose project name. -
The database. The password is read from the same file the stack already mounts as a secret, so there is no second copy to rotate:
mariadb_databases:- name: nextcloudcontainer: nextcloud-dbusername: nextcloudpassword: "{credential file /srv/docker/apps/nextcloud/.secrets/db_pwd.txt}"tls: false -
Maintenance mode around the capture. The error hook is what stops a failed run leaving the instance offline:
commands:- before: actionwhen: [create]run:- docker exec -u www-data nextcloud-app php occ maintenance:mode --on- after: actionwhen: [create]run:- docker exec -u www-data nextcloud-app php occ maintenance:mode --off- after: errorrun:- docker exec -u www-data nextcloud-app php occ maintenance:mode --off -
Validate, then run it once by hand:
Terminal window sudo borgmatic config validatesudo borgmatic create --verbosity 1 --stats
Restoring this stack
Section titled “Restoring this stack”Database first, then the file tree, then the application — and leave maintenance mode on until both halves are in place. The two come from the same archive; a database restored against an older file tree produces exactly the divergence maintenance mode exists to prevent.
The flag itself is inside the backup. Any archive taken while the quiescing hook
was active holds 'maintenance' => true in config.php, so a restored instance
refuses to serve until you clear it — nothing is served before somebody has
looked at it:
docker compose exec -u www-data app php occ maintenance:mode --offRehearse into a scratch database first and read the result, not the exit code —
the procedure is on Backup and restore.
A table count in the low hundreds, the accounts you expect in oc_users and a
non-empty oc_filecache are the signs of a good dump.
For a real restore into live service, take a dump of the current state first, even a broken one. Then:
-
Switch on maintenance mode and stop the application, leaving the database running:
Terminal window docker compose exec -u www-data app php occ maintenance:mode --ondocker compose stop app cron nextcloud-nginx -
Restore the database, then the file tree from the same archive.
-
Start the stack, clear maintenance mode, reconcile the file index with what is on disk, and check
/settings/admin/overviewbefore letting anyone back in:Terminal window docker compose up -ddocker compose exec -u www-data app php occ maintenance:mode --offdocker compose exec -u www-data app php occ files:scan --all
Updates
Section titled “Updates”Nextcloud upgrades one major version at a time. Skipping a version is not supported, and the web updater is disabled in this stack in favour of replacing the image.
- Read the release notes for the version you are moving to.
- Back up, and confirm the backup is readable.
- Raise
APP_TAGin.envby one major version. -
Terminal window docker compose pulldocker compose up -ddocker compose logs -f app - Check
occ statusforneedsDbUpgrade: false, then re-open/settings/admin/overview.
Troubleshooting
Section titled “Troubleshooting”Permission denied reading a secret, while the file looks correct on the
host — the file was replaced by an editor. Recreate the containers rather than
restarting them.
Mail silently does not send, occ reports NOAUTH — the web user cannot
read redis_pwd.txt or smtp_pwd.txt. Check the group ownership from the
installation steps. The container still reports healthy in this state.
Mail never arrives and the log shows no error — port 465 is blocked outbound, which many hosting companies do. Check it from the host:
openssl s_client -connect smtp.example.com:465 -brief </dev/nullCONNECTION ESTABLISHED means the port is open. If it hangs or is refused, set
SMTP_PORT=587 and SMTP_SECURE=tls, then recreate both containers that send
mail:
docker compose up -d --force-recreate app cronBrute-force protection locks out everyone at once — NC_TRUSTED_PROXIES is
incomplete, so every request appears to come from the proxy. Add the missing
subnet, including the IPv6 one.
Setup checks fail to reach the instance itself — no closed policy admits
a Docker network. They pass once the route is access-public; an office server
does not depend on them (the stack README, “Office servers behind a closed route”).
Repository files
Section titled “Repository files”apps/nextcloud/README.md— architecture, network exception, tuningapps/nextcloud/.env.exampleapps/nextcloud/UPSTREAM.md— pinned versions and upgrade notes
Official sources
Section titled “Official sources”© 2026 SecDockBlueCode Apache-2.0Site content CC BY-NC 4.0