all systems self-hosted

~/selfhostjournal $

← All articles

Nextcloud in Docker behind Traefik: my configuration explained

9–13 minutes

In short

Problem: A Nextcloud container starts in a minute. Making it fast, reachable over HTTPS, free of admin warnings and safe to back up takes a lot more than docker run nextcloud.

Solution: One compose project with Nextcloud, PostgreSQL, Redis and a cron container, published through Traefik, logins through Authentik, documents in the browser with the built-in Collabora server, and a nightly backup in maintenance mode.

Tested with: Nextcloud 35 (official nextcloud:35 image, PHP 8.5), PostgreSQL 16, Redis, Traefik 3.7, Authentik, Docker Compose on a small home server

Nextcloud is the service in my homelab that the whole family actually uses: files, calendars, contacts, notes and, since recently, Office documents. That makes it the one I least want to break. This article walks through my configuration: the containers, the settings that matter behind a reverse proxy, single sign-on, Nextcloud Office, the maintenance after updates, and how I back it up. It builds on my Traefik article; if you don’t have a reverse proxy yet, start there.

1. The stack at a glance

ContainerImageJob
nextcloudnextcloud:35The web app (Apache + PHP)
postgrespostgres:16The database. Much happier than SQLite once more than one person syncs
redisredisFile locking and shared cache
nextcloud-cronnextcloud:35Runs the background jobs every 5 minutes

The tag nextcloud:35 is a deliberate choice. It follows minor and patch releases of version 35, so my update script (it runs docker compose pull && docker compose up -d three times a week) picks up security fixes on its own. A new major version never arrives by surprise. Nextcloud can only upgrade one major version at a time, and I want to read the release notes and have a fresh backup before that step.

2. The .env file

Everything that differs between installations, and every password, lives in a .env file next to the compose file. Make it readable only for its owner: chmod 600 .env.

NEXTCLOUD_IMAGE_TAG=nextcloud:35
NEXTCLOUD_HOSTNAME=cloud.example.com
NEXTCLOUD_URL=https://cloud.example.com
NEXTCLOUD_TIMEZONE=Europe/Berlin

NEXTCLOUD_DB_NAME=nextcloud
NEXTCLOUD_DB_USER=nextcloud
NEXTCLOUD_DB_PASSWORD=change-me-long-random

NEXTCLOUD_REDIS_PASSWORD=change-me-too

# only used on the very first start
NEXTCLOUD_ADMIN_USERNAME=admin
NEXTCLOUD_ADMIN_PASSWORD=change-me-as-well

A good way to create the passwords: openssl rand -base64 32. The admin user and password are only read when Nextcloud installs itself on the first start. Changing them later in .env does nothing.

3. Database, Redis and cron

networks:
  nextcloud-network:
  proxy:
    external: true

volumes:
  nextcloud-app:
  nextcloud-postgres:
  redis-data:

services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    volumes:
      - nextcloud-postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${NEXTCLOUD_DB_NAME}
      POSTGRES_USER: ${NEXTCLOUD_DB_USER}
      POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
    networks:
      - nextcloud-network
    healthcheck:
      test: ["CMD", "pg_isready", "-q", "-d", "${NEXTCLOUD_DB_NAME}", "-U", "${NEXTCLOUD_DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 60s

  redis:
    image: redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "${NEXTCLOUD_REDIS_PASSWORD}"]
    volumes:
      - redis-data:/data
    networks:
      - nextcloud-network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

  nextcloud-cron:
    image: ${NEXTCLOUD_IMAGE_TAG}
    restart: unless-stopped
    entrypoint: /cron.sh
    volumes:
      - nextcloud-app:/var/www/html
      - /srv/nextcloud/data:/var/www/html/data
    networks:
      - nextcloud-network

A few details that are easy to miss:

  • The database and Redis are only in nextcloud-network. They have no published ports and Traefik can’t see them. Only Nextcloud talks to them.
  • Health checks matter here. Nextcloud waits for service_healthy (see below), so it never starts its install or upgrade step against a database that is still booting.
  • The cron container uses the same image and the same volumes as Nextcloud, but runs /cron.sh, which calls cron.php every 5 minutes. After the first start, switch Nextcloud from AJAX to cron: occ background:cron (or Administration settings → Basic settings).

4. The Nextcloud container

  nextcloud:
    image: ${NEXTCLOUD_IMAGE_TAG}
    restart: unless-stopped
    volumes:
      - nextcloud-app:/var/www/html
      - /srv/nextcloud/data:/var/www/html/data
    environment:
      TZ: ${NEXTCLOUD_TIMEZONE}
      POSTGRES_HOST: postgres
      POSTGRES_DB: ${NEXTCLOUD_DB_NAME}
      POSTGRES_USER: ${NEXTCLOUD_DB_USER}
      POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_HOST_PORT: 6379
      REDIS_HOST_PASSWORD: ${NEXTCLOUD_REDIS_PASSWORD}
      NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USERNAME}
      NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
      NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_HOSTNAME}
      # behind the reverse proxy
      OVERWRITEHOST: ${NEXTCLOUD_HOSTNAME}
      OVERWRITEPROTOCOL: https
      OVERWRITECLIURL: ${NEXTCLOUD_URL}
      TRUSTED_PROXIES: 172.16.0.0/12
      # big uploads and Office
      PHP_MEMORY_LIMIT: 8G
      PHP_UPLOAD_LIMIT: 8G
    networks:
      - nextcloud-network
      - proxy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80/"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 90s
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

Two storage locations. The named volume nextcloud-app holds the application itself, including config/config.php and the apps you install from the app store (custom_apps). The user files go to a bind mount on a big disk (/srv/nextcloud/data here). Keeping them apart lets me put the data on a separate SSD and back the two up differently. Just don’t forget the volume: config.php contains the instance’s secret and password salt, and without it a restore of the data alone is much harder.

The reverse proxy settings. Nextcloud only sees plain HTTP from Traefik on port 80. Without these four variables it builds wrong links, refuses logins from the “untrusted” host name or shows every visitor with Traefik’s IP address:

VariableWhy
OVERWRITEHOSTThe host name Nextcloud uses in links and redirects
OVERWRITEPROTOCOL=httpsLinks start with https even though the request inside Docker is http. Without it, login redirects loop or break
OVERWRITECLIURLCorrect links in e-mails and in anything that background jobs or occ generate
TRUSTED_PROXIESNextcloud only believes the X-Forwarded-For header from these addresses. Then the log, the brute-force protection and the “last login” show the real client IP

172.16.0.0/12 covers Docker’s default network ranges, where Traefik lives. Put in what docker network inspect proxy shows on your host, and don’t add more than that: every range you trust can fake client IPs.

5. Publishing it through Traefik

    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.nextcloud.rule=Host(`cloud.example.com`)"
      - "traefik.http.routers.nextcloud.entrypoints=websecure"
      - "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt"
      - "traefik.http.routers.nextcloud.middlewares=nextcloud-dav,nextcloud-wellknown,nextcloud-compress,nextcloud-hsts"
      - "traefik.http.services.nextcloud.loadbalancer.server.port=80"

      # CalDAV/CardDAV discovery for phones and calendar apps
      - "traefik.http.middlewares.nextcloud-dav.redirectregex.permanent=true"
      - "traefik.http.middlewares.nextcloud-dav.redirectregex.regex=https?://([^/]*)/.well-known/(card|cal)dav"
      - "traefik.http.middlewares.nextcloud-dav.redirectregex.replacement=https://$${1}/remote.php/dav/"

      # all other .well-known URLs (webfinger, nodeinfo, ...)
      - "traefik.http.middlewares.nextcloud-wellknown.redirectregex.permanent=true"
      - "traefik.http.middlewares.nextcloud-wellknown.redirectregex.regex=https?://([^/]*)(/.well-known[^#]*)"
      - "traefik.http.middlewares.nextcloud-wellknown.redirectregex.replacement=https://$${1}/index.php$${2}"

      - "traefik.http.middlewares.nextcloud-compress.compress=true"
      - "traefik.http.middlewares.nextcloud-hsts.headers.stsSeconds=15552000"

The two redirects fix the most common warning in the admin overview (“Your web server is not properly set up to resolve /.well-known/caldav”). They also let phones find calendars and contacts when you only type the host name. The $$ is needed because Compose would otherwise read ${1} as a variable.

HSTS (the stsSeconds line) tells browsers to use only HTTPS for this host for the next 180 days, which removes another admin warning. In my setup, a general security header middleware on the websecure entrypoint sets HSTS for every service anyway (see the hardening article). I keep the line here so the Nextcloud stack still gets it if I ever change that.

One middlewares line per router: if a compose file sets traefik.http.routers.nextcloud.middlewares twice, there is no error. The second line silently replaces the first. My own file had exactly that for a long time: an earlier line with a buffering middleware was never active, and I only noticed while writing this article (docker inspect shows the labels the container really has). Put all middlewares into one comma-separated line.

You don’t need a buffering middleware for big uploads, by the way. Traefik has no request size limit by default, and the Nextcloud clients upload large files in chunks anyway.

6. Logging in with Authentik

Like most of my services, Nextcloud uses single sign-on with Authentik. I use the official app OpenID Connect user backend (user_oidc). The setup on the Authentik side (an OAuth2/OpenID provider plus an application) is the same as in my WordPress SSO article. The redirect URI for Nextcloud is https://cloud.example.com/apps/user_oidc/code.

Then, in the Nextcloud container:

alias occ='docker compose exec -u www-data nextcloud php occ'

occ app:install user_oidc

occ user_oidc:provider Authentik \
  --clientid="<client id from Authentik>" \
  --clientsecret="<client secret from Authentik>" \
  --discoveryuri="https://id.example.com/application/o/nextcloud/.well-known/openid-configuration" \
  --scope="openid email profile" \
  --mapping-uid=preferred_username \
  --mapping-display-name=name \
  --mapping-email=email \
  --unique-uid=0

The last two options are the important ones. --mapping-uid=preferred_username with --unique-uid=0 makes the Nextcloud user name equal to the Authentik user name, instead of a long hash. So an Authentik user anna lands in the existing Nextcloud account anna with all her files, and the paths stay readable.

The login page now shows a “Log in with Authentik” button. The normal Nextcloud login stays available below it. I keep it on purpose: if Authentik is down, the local admin account still works. Desktop and mobile clients log in through the browser, so they use SSO too.

Tip: don’t put Authentik’s forward-auth middleware in front of Nextcloud. It breaks the sync clients and WebDAV, which can’t do a browser login on every request. Use the OIDC app instead.

7. Nextcloud Office without an extra container

For opening .docx, .xlsx and .odt files in the browser I use Nextcloud Office with the built-in CODE server (Collabora Online Development Edition). It runs inside the Nextcloud container: no extra container, no extra host name, no extra certificate. For a household with a handful of users that’s plenty.

occ app:install richdocumentscode   # the built-in Collabora server (on ARM: richdocumentscode_arm64)
occ app:install richdocuments       # the Nextcloud Office app
occ richdocuments:activate-config

After that, Administration settings → Office should say that the built-in server is used. The WOPI URL points to https://cloud.example.com/custom_apps/richdocumentscode/proxy.php?req=.

Watch your security headers: the editor runs in an iframe inside Nextcloud. A global X-Frame-Options: DENY or SAMEORIGIN header from the reverse proxy breaks it with an empty, grey document. Nextcloud sets its own frame headers, so leave that header to the app.

Also check that the Office app version fits your Nextcloud major version. The app store sometimes keeps an older, incompatible version installed after an upgrade; occ app:update --all fixes that.

8. Clean admin overview after an update

After a major upgrade, Administration settings → Overview usually shows a few new warnings. These are the commands I needed, roughly in this order (with a fresh backup):

# new database indices that came with the upgrade (takes seconds to minutes)
occ db:add-missing-indices

# only if the overview suggests it; can take a while on big instances
occ maintenance:repair --include-expensive

# phone numbers in profiles without a country code
occ config:system:set default_phone_region --value="DE"

# run heavy background jobs at night (hour in UTC, 1 = 01:00-05:00)
occ config:system:set maintenance_window_start --type=integer --value=1

# the same checks as the admin overview, on the command line
occ setupchecks

Memory caching is configured automatically by the official image: APCu for the local cache and Redis for file locking, as soon as REDIS_HOST is set. You can check it with occ config:system:get memcache.locking.

9. Backups

Nextcloud is backed up every night by a script on the host, not inside the stack. The order matters:

  1. occ maintenance:mode --on, so no file or database row changes during the copy
  2. pg_dump of the database into a plain SQL file (never copy the live database files)
  3. rsync of the data folder into a dated snapshot, with a bandwidth limit so the disks stay usable
  4. a copy of config/, custom_apps/ and themes/ from the app volume, plus the compose file and .env
  5. occ maintenance:mode --off, in a trap, so it also happens when one step fails

On a normal night, maintenance mode lasts a few minutes. Unchanged files are hard-linked between snapshots, so 14 days of history cost little more than one copy. How the snapshots, the database dumps and the offline copy work together is in my backup article.

Tip: after a restore, run occ maintenance:data-fingerprint. It tells the sync clients that the server went back in time, so they ask before overwriting newer files instead of silently syncing in either direction.

10. When something doesn’t work

SymptomUsual cause
“Access through untrusted domain”The host name is missing in trusted_domains. NEXTCLOUD_TRUSTED_DOMAINS only works on the first install; later use occ config:system:set trusted_domains 1 --value=cloud.example.com
Login loops back to the login page, or links start with http://OVERWRITEPROTOCOL=https missing
Every log entry shows the same internal IPTRUSTED_PROXIES doesn’t contain Traefik’s network
“Your web server is not properly set up to resolve /.well-known/caldav”The redirect middlewares are not active. Check with docker inspect which middlewares the router really got
Office documents open as an empty grey pageAn X-Frame-Options header from the proxy, or an Office app version that doesn’t fit the Nextcloud version
“Last background job execution ran … hours ago”The cron container isn’t running, or Nextcloud is still set to AJAX

The most useful commands for the rest:

# the newest errors from Nextcloud's own log
occ log:tail 20

# what the admin overview would complain about
occ setupchecks

# which labels (and so which Traefik middlewares) the container really has
docker inspect nextcloud-nextcloud-1 --format '{{json .Config.Labels}}'

What I learned

  • Pin the major version. nextcloud:35 gives me automatic security fixes, and the big upgrade happens when I decide, with a backup.
  • The reverse proxy settings are not optional. Most strange Nextcloud problems behind Traefik came down to OVERWRITEPROTOCOL or TRUSTED_PROXIES.
  • Check what is really running. A label that is overwritten by a later one raises no error. docker inspect shows the truth, the compose file only shows the intention.
  • The app volume belongs in the backup. For a long time I only backed up the data folder and the database. Without config.php, that’s a much harder restore.
  • SSO via OIDC, not forward-auth. The sync clients keep working, and the local login is my emergency exit.

Everything here worked in my setup at the time of writing. Software changes, and your setup is different: back up first, test carefully, and use the steps at your own risk. Details in the legal notice.