Get Rewarded! We will reward you with up to €50 credit on your account for every tutorial that you write and we publish!

Nextcloud with fully encrypted storage

profile picture
Author
Justin Scholz
Published
2025-08-20
Time to read
75 minutes reading time

About the author- Always striving to hit the right balance.

Introduction

In light of current events — waves around — putting your own private data into a US based cloud, regardless of whether you are a US citizen or a EU citizen or somewhere else, might not be the most advisable option anymore. Especially if it's not encrypted, as it can then be scanned, AI trained on and many more things.

So I set out to run my own Nextcloud — pretty much a European open-source cloudware that is a bit akin to Google Workspace without email hosting.

What I wanted to achieve:

  • Low costs
  • Controllable costs (I want them predictable - not like an AWS bill)
  • European data center
  • Expandable storage without rebuilding the server
  • Fully encrypted with proper key separation
  • Syncing of files, calendar, contacts and the ability to host my own video calls à la Zoom

The key security insight: Nextcloud's server-side encryption stores the encryption keys in the same data directory as the encrypted files. If you simply point your data directory at a Storage Box, both your encrypted files AND the keys to decrypt them live in the same place — defeating much of the purpose.

This guide takes a different approach: encryption keys stay on your LUKS-encrypted VPS disk, while the bulk encrypted file storage lives on the cheap, expandable Storage Box. If someone gains access to your Storage Box, they get only encrypted blobs with no way to decrypt them.

Note for existing users: A previous version of this guide stored the entire data directory on the Storage Box, which meant encryption keys and encrypted files lived together. If you followed the earlier guide, see the Migration from Previous Guide section for steps to improve your security posture.

The setup has survived stress tests of 120,000+ files, multiple reboots, and months of daily use.

What this guide covers:

  • Setting up the system with proper encryption architecture
  • Getting Nextcloud running with Nextcloud AIO
  • Configuring per-user storage offloading to the Storage Box

What this guide does NOT cover:

  • Day-to-day Nextcloud usage and administration
  • Ongoing system maintenance in depth — though we do set up update and reboot notifications (Step 13) so the box tells you when it needs attention

The Nextcloud desktop client for Mac and Windows supports virtual file systems. This means you can sync some folders fully to your device while having others available on-demand through the file provider API — useful for large archives you don't need locally all the time.

Architecture Overview

Before diving into the setup, it helps to understand what we're building and why.

The Problem with Naive Setups

When you enable Nextcloud's server-side encryption, it creates a files_encryption folder containing the keys needed to decrypt your files. If your entire data directory lives on remote storage (like a Storage Box), then anyone with access to that storage has both:

  • Your encrypted files
  • The keys to decrypt them

This is like locking your front door and leaving the key under the doormat.

Our Solution: Key Separation

┌─────────────────────────────────────────────────────────────────────────┐
│  VPS (LUKS-encrypted disk)                                              │
│                                                                         │
│  Nextcloud Data Directory (local Docker volume)                         │
│  ├── files_encryption/    ← Master encryption keys (NEVER leave disk)  │
│  ├── appdata_*/           ← App cache and config                        │
│  │                                                                      │
│  └── <username>/                                                        │
│      ├── files_encryption/ ← Per-user keys (stay local)                │
│      ├── cache/            ← User cache (stays local)                  │
│      ├── files/           ─┐                                            │
│      ├── files_trashbin/   ├─ Bind mounts to Storage Box               │
│      ├── files_versions/   │  (only encrypted content travels there)   │
│      └── uploads/         ─┘                                            │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                                    │ bind mounts (per-user, 4 folders each)
                                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│  Storage Box (SMB mount)                                                │
│                                                                         │
│  └── <username>/                                                        │
│      ├── files/           ← Encrypted blobs only                        │
│      ├── files_trashbin/  ← Encrypted                                   │
│      ├── files_versions/  ← Encrypted                                   │
│      └── uploads/         ← Encrypted                                   │
└─────────────────────────────────────────────────────────────────────────┘

Security Properties

Component Location Protected by
Encryption keys VPS local disk LUKS full-disk encryption (boot passphrase)
Encrypted files Storage Box Nextcloud server-side encryption
Nextcloud config VPS local disk LUKS full-disk encryption
Elasticsearch index (optional, Step 15) Storage Box, inside a LUKS container file LUKS — key file stays on the VPS

What this means in practice:

  • Storage Box compromised? Attacker gets encrypted blobs, useless without keys.
  • VPS disk stolen while powered off? Protected by LUKS encryption.
  • VPS compromised while running? Keys are in memory — this is outside our threat model (if you need protection against this, don't run on shared infrastructure).

Optional: large data that is not user files

Anything bulky that is not a user file can follow the same pattern — put it on the Storage Box inside a LUKS container file whose key never leaves the VPS. Step 15 does exactly that for the Elasticsearch full-text search index, which can easily outgrow the small local disk. The container file is loop-mounted, unlocked at boot from a key on the encrypted VPS disk, and mounted into the relevant Docker volume, so the Storage Box only ever holds ciphertext.

The Trade-off: Per-User Setup

This architecture requires adding bind mounts for each user you want to offload to the Storage Box. New users default to local storage (secure by default), and you explicitly choose which users to offload. This is a small administrative overhead for significantly better security.

Why I care about encryption

It might not be often, but it does happen that law enforcement confiscates physical hardware in a data center for analysis, or that people get unauthorised physical access. By requiring manual entry of the encryption key after a power event, I get to decide whether to unlock my data or not. Storing the encryption key at boot is like taping it next to your door outside your house "just to have it handy all the time".

Prerequisites

  • VPS on Hetzner (in their parlance a cloud server) with 4GB of RAM, 2 CPU cores and 40GB of NVME storage
  • A Hetzner Storage Box — essentially a 1TB+ NAS in the cloud for an incredibly low price
  • A domain name with the ability to set A and AAAA records
  • Basic familiarity with Linux command line and SSH

Both resources should be in the same Hetzner region to keep traffic internal and latency low.

On RAM: 4GB is comfortable. 2GB can work if you set up swap (covered later), but you may experience slowdowns during heavy operations like full-text search indexing.

Step 1 - Creating the server

Create a new server with the architecture type x86 (!important!). With Hetzner, you can use CX23, for example — 2 vCPU is sufficient. 4 GB of RAM is comfortable; a smaller machine can work with swap (Step 7), though full-text search indexing in particular will suffer.

After you created the server, follow this guide:

How to install Ubuntu 24.04 with full disk encryption

Beware of the special section for Debian 13 with the following caveats:

  • When booted into the rescue system, you can check the full name of the Debian image by running ls /root/images and copying the Debian 13 image name into your pasteboard.

  • Your setup.conf should look like this:

    CRYPTPASSWORD secret
    DRIVE1 /dev/sda
    BOOTLOADER grub
    HOSTNAME host.example.com
    PART /boot ext4 1G
    PART /     ext4 all crypt
    IMAGE /root/images/Debian-1305-trixie-amd64-base.tar.zst
    SSHKEYS_URL /tmp/authorized_keys
  • Additional notice: If you add private networking it might happen that your Dropbear unlock only picks up the IP from your local network first and is unreachable over ssh. In this case temporarily disable the private network or discuss this issue with your preferred coding AI — the issue is that the private network wins the race of "which network interface responds with an IP address first".

Once that is setup, you can create yourself a Storage Box in the same region via Hetzner Console. Choose your preferred size. Create a subaccount with limited access to a specific subfolder for your Nextcloud and enable SMB access. You DON'T need to enable "external access" though as this stays in the Hetzner network.

Step 2 - Booting into the server

Now you can proceed with setting up your VPS. Do the basic setup first — the firewall section below assumes it, since that is where ufw gets enabled and the SSH rule added:

Initial Server Setup with Ubuntu

Let's open the relevant ports on UFW for all the stuff:

sudo ufw default deny incoming
sudo ufw default allow outgoing

# HTTP for ACME/Nextcloud challenge
sudo ufw allow 80/tcp comment 'ACME-HTTP-Nextcloud'

# HTTPS for Apache container (HTTP/1.1 & HTTP/2)
sudo ufw allow 443/tcp comment 'Apache-HTTPS'

# HTTP/3 (QUIC) for Apache container
sudo ufw allow 443/udp comment 'Apache-HTTP3-QUIC'

# Admin interface of master container
sudo ufw allow 8443/tcp comment 'Master-UI-HTTPS'

# TURN server (Talk container) – TCP & UDP
sudo ufw allow 3478/tcp comment 'TURN-TCP'
sudo ufw allow 3478/udp comment 'TURN-UDP'

Then confirm what you actually have:

sudo ufw status verbose

This assumes the linked initial-setup tutorial was done — that is what enables ufw and adds the SSH rule, which is why neither appears above. If status says inactive, or SSH is not listed, go back and do that first: enabling ufw with no SSH rule locks you out of your own server.

Do not add a rule for the Dropbear LUKS-unlock port (2222) here. That listener runs from the initramfs, before any ufw ruleset is loaded, so ufw never applies to it — its absence from this list is deliberate, not an omission.

Know what ufw does and does not cover here. Docker writes its own iptables rules (the DOCKER chain) that are consulted before ufw's INPUT chain, so ufw does not block a port that a bridge-network container publishes with -p. A container publishing 0.0.0.0:9000 is reachable from the internet even under default deny incoming with no matching ufw rule. Protection for such containers has to come from binding them to 127.0.0.1 (or a tailnet address) instead of relying on the firewall. Containers using network_mode: host bind like ordinary host daemons, and for those ufw does apply.

Once that is done, you can check your IPv4 address (if you ordered one) and IPv6 by running this command on the server:

ip -4 addr show
ip -6 addr show

Your interface is probably enp1s0.

Step 3 - Pointing DNS to your server

If you now go to the domain registrar of your own domain, you can adjust the (sub)domain's A and AAAA records for your IPv4 and IPv6 addresses respectively.

Once that is done, let's go back to your VPS.

Step 4 - Creating the SMB mount to the Storage Box

Back on the Hetzner VPS, go to a root shell with sudo su. Stay in the root shell during the rest of the guide.

This mount will hold the encrypted file content for users. It is NOT the Nextcloud data directory — that stays on the local LUKS-encrypted disk. We'll bind-mount specific user folders from here into the data directory later.

First, install SMB support:

apt update
apt install cifs-utils

Create the mount point (replace myshare with whatever you want to call it):

mkdir -p /mnt/myshare

Create a credentials file (our disk is encrypted, so storing credentials here is acceptable):

mkdir -p /etc/cifs-creds
nano /etc/cifs-creds/myshare

Add:

username=your_smb_username
password=your_smb_password

This username and password comes from your Storage Box sub account.

Secure the credentials file:

chmod 600 /etc/cifs-creds/myshare

Now create a systemd .mount unit:

nano /etc/systemd/system/mnt-myshare.mount

Important: The unit filename must match the mount path exactly, with / replaced by - and the leading slash removed. So /mnt/myshare becomes mnt-myshare.mount.

Add the following content:

Replace YOURSTORAGEBOXUSER-subX with your actual account name.

[Unit]
Description=Mount SMB Share myshare
DefaultDependencies=no
After=network-online.target
Wants=network-online.target

[Mount]
What=//YOURSTORAGEBOXUSER-subX.your-storagebox.de/YOURSTORAGEBOXUSER-subX
Where=/mnt/myshare
Type=cifs
Options=credentials=/etc/cifs-creds/myshare,iocharset=utf8,uid=33,gid=33,seal,vers=3.1.1,hard,_netdev
TimeoutSec=30

[Install]
WantedBy=multi-user.target

A few notes on this unit:

  • DefaultDependencies=no prevents systemd from adding automatic dependencies that can cause ordering cycles with network-dependent mounts
  • uid=33,gid=33 sets ownership to www-data (the user Nextcloud runs as)
  • seal enables SMB encryption in transit
  • _netdev tells the system this is a network mount
  • hard is the important one — see below

Do not omit hard. Unlike NFS, mount.cifs defaults to soft, which returns an error mid-write when the Storage Box becomes unreachable — and a write that fails halfway through leaves a truncated file. With server-side encryption on, a truncated blob is unrecoverable: it decrypts up to the cut and then fails a signature check. hard blocks and retries instead, trading availability for integrity.

The cost is real: during an outage, processes touching the mount hang in uninterruptible sleep, and Nextcloud's php-fpm workers will pile up. That is still far better than corrupting files. Consider putting Nextcloud into maintenance mode if you notice the Storage Box is down.

Check what is actually in force, not what the unit says: findmnt -no OPTIONS /mnt/myshare. A soft mount is not visible from the unit file alone if the mount predates an edit — CIFS will not switch between soft and hard on mount -o remount, so changes need a real remount or a reboot.

seal costs memory, and on a small VPS that can bite. SMB encryption allocates contiguous kernel memory per message. On a memory-constrained box under load — many small files being written at once, say a build directory syncing — that allocation can fail (order:4, GFP_NOFS inside crypt_message), and the client reports a copy error such as -1 byte. It is a failed write, not corruption: the existing file is untouched and a retry succeeds. Keep seal (in-transit encryption is worth it), set up swap as in Step 7, and exclude build and cache directories (node_modules/, .build/, and similar) from anything you sync.

Timeouts: CIFS uses echo_interval (default 60s), not NFS's timeo/retrans. Dead-server detection takes roughly 3× that, which is where the classic has not responded in 180 seconds message comes from. Any monitoring you write against this mount should wrap df/ls in timeout, or a hung mount will freeze the monitoring too.

Activate it:

systemctl daemon-reload
systemctl enable --now mnt-myshare.mount

If you get an error message like Failed to mount mnt-myshare.mount - Mount SMB Share myshare, double-check in Hetzner Console if the subaccount has "Allow SMB" enabled.

Verify it mounted:

mount | grep myshare
ls -la /mnt/myshare

You should see an empty directory owned by www-data.

Step 5 - Installing Docker

Follow the official guide to install Docker: https://docs.docker.com/engine/install/debian/

Once installed, make Docker wait for the SMB mount to be ready:

systemctl edit docker.service

Add above the line that says ### Edits below this comment will be discarded:

[Unit]
Requires=mnt-myshare.mount
After=mnt-myshare.mount

Reload systemd:

systemctl daemon-reload

Finally, enable IPv6 support for Docker (we're living in the 21st century): https://github.com/nextcloud/all-in-one/blob/main/docker-ipv6-support.md

You won't be able to verify IPv6 works until Nextcloud is running — that comes later.

Step 6 - Preparing Nextcloud AIO

I recommend running Nextcloud AIO through compose. This makes it easier to track changes and see the startup parameters.

mkdir -p ~/containers/nextcloud
cd ~/containers/nextcloud
nano compose.yml

Start with the official compose file from AIO:

github.com/nextcloud/all-in-one/blob/main/compose.yaml

Make the following adjustments:

  • Uncomment NEXTCLOUD_DATADIR: and set it explicitly to the local Docker volume:

    NEXTCLOUD_DATADIR: /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/

    Important: Do NOT point this at your SMB mount. The data directory must stay on the local LUKS-encrypted disk so that encryption keys remain separate from encrypted files. We'll bind-mount only the user file storage to the SMB mount later.

  • Uncomment NEXTCLOUD_MAX_TIME: and increase the value (I use 7200)

  • Uncomment NEXTCLOUD_MEMORY_LIMIT: and set it to 2048

  • Uncomment environment: above NEXTCLOUD_MAX_TIME: and NEXTCLOUD_MEMORY_LIMIT:

  • If you plan to use full-text search, uncomment FULLTEXTSEARCH_JAVA_OPTIONS: and set reasonable values:

    FULLTEXTSEARCH_JAVA_OPTIONS: "-Xms1024M -Xmx2048M"

Step 7 - Setting up swap to help with memory pressure

Before we start running any containers, let's enable Swap on the server so that we don't run out of RAM:

fallocate -l 4G /swapfile
dd if=/dev/zero of=/swapfile bs=1M count=4096 status=progress
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile

Let's add it to system boot up:

echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

Make the kernel reach for swap only when it has to. 10 is well below the default of 60, so this reduces swapping — swap stays a safety net rather than a routine tier:

echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p                    # apply now; without this it only takes effect at next boot
sysctl vm.swappiness              # must print 10 - htop shows swap size, not this setting

You can verify the swapfile itself with:

htop

It should show memory and swap separately. Swap should show x/4.00G.

Especially if you are on the 2GB RAM box, 4G of Swap were a good idea when also running fulltext search

Step 8 - Starting it up

Make sure you're in the right directory:

If you haven't yet adjusted the DNS on your domain registrar to point to your Hetzner server, now is the time.

cd ~/containers/nextcloud
docker compose up -d

Now continuing the guide on nextcloud/all-in-one/blob/main/readme.md, let's open https://example.com:8443 and go to your AIO install interface. Use your own domain.

If https://example.com:8443 doesn't load for you, try https://example.com:8080 instead.

Follow the steps. At the end it will show a temporary password for the user admin that you can then use to login under https://example.com:443 with the data store in the back being on your Storage Box.

Step 9 - Enabling encryption

Once logged in to the Nextcloud, you should:

Description
Encrypt all files Head to your user icon on the top right => Admin settings => security settings => server side encryption => switch it on
Activate the encryption module Head to your user icon on the top right => apps => deactivated apps and activate the "default encryption module"
Encrypt user home folders Head back to admin settings => security settings => encryption
Check that the checkbox is ticked for "encrypt user home folders"

Encryption applies from now on, not retroactively. Files stored after this point are encrypted; anything already there stays as it is. (For the ways this can still go subtly wrong, see Server-side encryption gotchas in Step 14.) On a fresh install that means Nextcloud's default skeleton — the sample PDFs, images and Templates/ folder each user receives at first login — remains plaintext, and Step 10 is about to move it onto the Storage Box that way.

That is stock demo content, not your data — nothing to act on. It is also the answer if you later run the HBEGIN probe from Step 14 against one of those files and are surprised it comes back plaintext.

If you want, you can stop your containers and enable some of the optional containers here.

Except full-text search. It has its own step (Step 15), because its Elasticsearch index can easily outgrow the local disk and needs an encrypted volume on the Storage Box prepared before the container first starts. Enabling it here puts the index on the local disk instead.

Step 10 - Setting up per-user storage offloading

Now that Nextcloud is running with encryption enabled, we'll configure specific users to store their files on the Storage Box while keeping encryption keys local.

How it works: Each Nextcloud user has four storage folders:

  • files/ — their actual files
  • files_trashbin/ — deleted files (before permanent deletion)
  • files_versions/ — version history
  • uploads/ — temporary storage during uploads

We create these folders on the Storage Box, then bind-mount them into the Nextcloud data directory. Nextcloud sees them as local folders, but the data actually lives on the Storage Box — encrypted.

Setting up a user (example: admin)

Every user already has files — including a brand new one. Nextcloud copies a skeleton (Documents/, Photos/, Templates/, a few PDFs) into a user's files/ the first time they log in, and records it in oc_filecache. You logged in as admin back in Step 9 to switch encryption on, so on a fresh install admin already has one before you get here.

A bind mount hides whatever is underneath it. Mount an empty Storage Box directory over files/ and those rows stay in the database while the files vanish from disk — Nextcloud then refuses to create documents or accept uploads in exactly those folders, with no useful error. So move the existing content across before mounting it.

Stop the containers first — nothing may write into the data directory while you move it. Use "Stop containers" in the AIO interface (https://example.com:8443, the one from Step 8), not docker compose down: the AIO containers carry an unless-stopped restart policy, so taking the mastercontainer down leaves them running.

Check what Nextcloud has already created for the user:

ls -la /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/
ls -la /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files/

You should see files/ at minimum, holding the skeleton. If the other folders don't exist yet, they'll be created when needed.

Create the corresponding folder structure on the Storage Box:

mkdir -p /mnt/myshare/_data/admin/{files,files_trashbin,files_versions,uploads}
chown -R www-data:www-data /mnt/myshare/_data/admin

Move what is already there onto the Storage Box. shopt -s dotglob matters — a plain * skips dotfiles and would leave them behind to be hidden by the mount:

shopt -s dotglob
mv /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files/*           /mnt/myshare/_data/admin/files/
mv /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files_trashbin/*  /mnt/myshare/_data/admin/files_trashbin/
mv /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files_versions/*  /mnt/myshare/_data/admin/files_versions/
mv /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/uploads/*         /mnt/myshare/_data/admin/uploads/
shopt -u dotglob

On a fresh install only files/ normally has anything in it. No such file or directory or cannot stat on the other three is expected and harmless — files/ is the one that must move. Do not touch files_encryption/; it stays on the local disk.

Create the target directories in the Docker volume (if they don't exist):

mkdir -p /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/{files,files_trashbin,files_versions,uploads}
chown -R www-data:www-data /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin

Add the bind mounts to /etc/fstab:

nano /etc/fstab

Add these lines (adjust myshare to your mount name):

# Nextcloud user: admin
/mnt/myshare/_data/admin/files            /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files            none  bind,nofail,x-systemd.requires-mounts-for=/mnt/myshare,x-systemd.before=nextcloud-mounts-check.service,x-systemd.device-timeout=30s  0  0
/mnt/myshare/_data/admin/files_trashbin   /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files_trashbin   none  bind,nofail,x-systemd.requires-mounts-for=/mnt/myshare,x-systemd.before=nextcloud-mounts-check.service,x-systemd.device-timeout=30s  0  0
/mnt/myshare/_data/admin/files_versions   /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files_versions   none  bind,nofail,x-systemd.requires-mounts-for=/mnt/myshare,x-systemd.before=nextcloud-mounts-check.service,x-systemd.device-timeout=30s  0  0
/mnt/myshare/_data/admin/uploads          /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/uploads          none  bind,nofail,x-systemd.requires-mounts-for=/mnt/myshare,x-systemd.before=nextcloud-mounts-check.service,x-systemd.device-timeout=30s  0  0

Understanding the mount options:

  • bind — this is a bind mount, not a device mount
  • nofail — boot continues even if mount fails (prevents boot hang)
  • x-systemd.requires-mounts-for=/mnt/myshare — wait for SMB mount first
  • x-systemd.before=nextcloud-mounts-check.service — order this mount before the check you build in Step 11. Without it that check races the mounts it verifies and can stop Docker from starting; the reasoning is in Step 11
  • x-systemd.device-timeout=30s — timeout if mount takes too long

Order the mounts from fstab, not from the service unit. RequiresMountsFor= in the service unit looks like the tidier way to express this, and it is what you will find suggested elsewhere. It was tried first here and did not produce the dependency for paths containing a space — the four ordinary usernames were ordered, the two with spaces were not, and systemctl show on the service looked healthy either way. Declaring the systemd-escape'd unit name as an explicit Requires=/After= did not work either. That is reported as observed on one Debian 13 box, not as a general rule; what matters is that x-systemd.before= in fstab has no such problem, because the path there is already written in fstab's own \040 form and never has to survive a second layer of quoting.

Mount them now:

systemctl daemon-reload
mount -a

Verify they're active:

mount | grep admin/files
mount | grep admin/uploads

You should see four bind mounts.

Now start the containers again from the AIO interface. There is no files:scan to run: mv keeps names, sizes and modification times, and the bind mount puts the same tree back at the same path, so Nextcloud's oc_filecache still matches what is on disk. The move is invisible from Nextcloud's side, which is the point.

Usernames with spaces

If a username contains spaces (like "Hans Werner"), escape spaces with \040 in fstab:

/mnt/myshare/_data/Hans\040Werner/files  /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/Hans\040Werner/files  none  bind,nofail,x-systemd.requires-mounts-for=/mnt/myshare,x-systemd.before=nextcloud-mounts-check.service,x-systemd.device-timeout=30s  0  0

The \040 escaping is an fstab convention and applies there only. In the shell commands above, quote the path instead:

mkdir -p "/mnt/myshare/_data/Hans Werner"/{files,files_trashbin,files_versions,uploads}
shopt -s dotglob
mv "/var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/Hans Werner/files"/* "/mnt/myshare/_data/Hans Werner/files/"
shopt -u dotglob

Already mounted over the files? (recovery)

If you mounted before moving — or followed an earlier version of this guide — the content is not lost, only hidden underneath the mount. The symptom is a user who can browse Documents/ and Photos/ in the web UI but gets an error creating a file or uploading an image there.

  1. Stop the containers from the AIO interface.
  2. Release the bind mount so the original directory becomes visible again:
    umount /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files
    ls -la /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files
    Whatever is listed now is the hidden content. Repeat for files_trashbin, files_versions and uploads if they were mounted too.
  3. Move it across and remount:
    shopt -s dotglob
    mv /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files/* /mnt/myshare/_data/admin/files/
    shopt -u dotglob
    chown -R www-data:www-data /mnt/myshare/_data/admin
    mount -a
  4. Start the containers, then rescan:
    docker exec --user www-data nextcloud-aio-nextcloud php occ files:scan admin
    Unlike the clean path above, the scan is needed here: while the content was hidden, folders may have been recreated by hand and oc_filecache may already have been written against the empty directory. The scan is what puts the two back in agreement.

If the directory underneath turns out to be empty, run the scan anyway with the mounts up: it clears the stale oc_filecache rows, and the folders can then be recreated normally from the web UI.

Important notes

  • New users default to local storage. This is secure by default — encryption keys and files are both on the LUKS disk. You choose which users to offload.
  • Don't bind-mount files_encryption! That folder contains encryption keys and must stay on the local disk.
  • Changes are per-user. You can have some users on local storage and others offloaded to the Storage Box.

Step 11 - Adding the mounts verification service

Now that you have bind mounts configured, we'll add a safety check that prevents Docker from starting if the mounts aren't ready. This avoids a situation where Nextcloud starts with unmounted directories and writes files to the wrong location.

Create the verification service

nano /etc/systemd/system/nextcloud-mounts-check.service

Add the following (adjust usernames to match your setup):

[Unit]
Description=Verify Nextcloud bind mounts are active
After=local-fs.target remote-fs.target mnt-myshare.mount
Requires=mnt-myshare.mount

[Service]
Type=oneshot
ExecStart=/bin/bash -c '\
  mountpoint -q /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files'
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

For each additional user with bind mounts, add another mountpoint check with &&:

ExecStart=/bin/bash -c '\
  mountpoint -q /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files && \
  mountpoint -q /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/justin/files && \
  mountpoint -q "/var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/Hans Werner/files"'

Note: Usernames with spaces need to be quoted within the bash command.

This check must be ordered after the mounts, and nofail is why it is not by default. The fstab entries in Step 10 carry nofail, which does more than let the boot continue — systemd also stops ordering those mounts before local-fs.target (these binds have no _netdev and fstype none, so systemd classifies them as local, not network). So this service and the bind mounts it checks become runnable at the same moment. On some boots the check wins, mountpoint -q returns false, and because docker.service requires this unit, Docker does not start at all. Intermittent, and the cause sits three units away from the symptom.

The x-systemd.before=nextcloud-mounts-check.service option on every bind line in Step 10 — all four folders per user, not only files — is what closes it: each mount is ordered before this service, so the check cannot run early. Ordering all four means Docker also waits for uploads, files_versions and files_trashbin, which is the whole point of the gate.

The check itself probes only files, deliberately: the four binds share one source filesystem and one timeout, so they establish or fail together, and files serves as the sentinel for the group.

Verify from the mount side, not from the service. x-systemd.before writes Before= onto the mount unit, and the inverse After= does not appear in this service's properties until systemd has loaded that mount unit — so checking the service under-reports and looks like the fix did not apply:

systemctl show "$(systemd-escape -p --suffix=mount \
  "/var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files")" -p Before --value

Quote the path — for a username with a space the shell would otherwise hand systemd-escape two arguments, which is exactly the case this whole mechanism exists to handle.

That must name nextcloud-mounts-check.service. Only a reboot proves the race is closed — a boot that succeeded before the change proves nothing, since it was a race you were winning.

If you ever do hit a failed check, nothing is damaged. Confirm the mounts with findmnt; if any are genuinely absent, mount -a first, then systemctl start nextcloud-mounts-check.service docker.service. A failed oneshot restarts without needing systemctl reset-failed.

Enable the service:

systemctl daemon-reload
systemctl enable nextcloud-mounts-check.service

Update Docker to require the verification

systemctl edit docker.service

Update the override to include the mounts-check service:

[Unit]
Requires=mnt-myshare.mount nextcloud-mounts-check.service
After=mnt-myshare.mount nextcloud-mounts-check.service

Apply the changes:

systemctl daemon-reload

Test it

Reboot and verify everything comes up correctly:

reboot

After reboot, remember to connect to port 2222 as root and unlock the encrypted partition via cryptroot-unlock. Then connect to your user and run sudo su to get back into root. After the system is back up:

# Check the mounts verification passed
systemctl --no-pager status nextcloud-mounts-check.service

# Check Docker started successfully
systemctl --no-pager status docker.service

# Check the bind mounts are active
mount | grep nextcloud_aio_nextcloud_data

Maintaining the verification service

Whenever you add bind mounts for a new user (Step 10), remember to:

  1. Add a mountpoint check for that user to the service
  2. Reload: systemctl daemon-reload

This is a small bit of maintenance, but it prevents silent failures where files end up in the wrong place.

Why this check matters more than it looks

The failure this guards against is not "Nextcloud cannot reach the files" — it is "Nextcloud can see the directory, but the data is not behind it". A missing bind mount leaves an ordinary, readable, empty directory at /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/<user>/files (which is what Nextcloud sees, from inside the container, as /mnt/ncdata/<user>/files). Nothing errors. Nextcloud simply believes the user has no files.

That is merely wrong until someone runs a scan:

occ files:scan --all      # DO NOT run this if you are unsure the bind mounts are up

files:scan faithfully records what it finds, so against empty directories it marks every file as removed and purges them from oc_filecache. The encrypted blobs are still on the Storage Box, but Nextcloud no longer knows they exist.

Before any files:scan, confirm the mounts are actually up:

systemctl --no-pager status nextcloud-mounts-check.service
ls /mnt/myshare/_data/<username>/files | head

Note that the hard mount option from Step 4 protects you here too: if the Storage Box is unreachable, a scan blocks instead of reading back empty. The dangerous states are the ones where the directory exists and is empty — bind mounts not established after a boot, unmounted by hand, or a soft mount returning errors mid-scan.

Step 12 - Enabling Nextcloud backup (Optional)

Nextcloud AIO includes built-in borg backup. This backs up your Nextcloud configuration, database, and importantly, runs the automatic update process. We'll configure it to back up to the Storage Box while excluding the large data directory.

Create a backup subaccount

Create a new sub account on your Storage Box with:

  • Access restricted to a backup folder
  • SSH access only (no SMB needed)

Access the AIO interface

Go to the admin settings at:

https://example.com/settings/admin/overview

At the top of the page, click on Open Nextcloud AIO Interface

Configure borg in AIO

In the AIO interface, enter your backup destination (remote borg repo):

ssh://YOURBOXID-subX@YOURBOXID-subX.your-storagebox.de:23/./nextcloud-aio-borg

The . gets you to the backup sub folder. The additional subfolder is necessary.

Once you click on Submit backup location, you should see an SSH key in the AIO interface. If not, click Create backup. Note that this will turn off all containers. You will get a warning like this:

Backup and restore

Last backup failed! (Logs)

The initial backup was not successful.

You may still need to authorize this pubkey on your borg remote:
ssh-ed25519 <key> root@nextcloud-aio-borgbackup

Copy the SSH key, then click Start containers above.

If you later follow Step 15, note that AIO's default backup set includes the Elasticsearch volume, and a full-text index can be the largest thing in the backup. There is no per-volume exclusion. See the note at the end of Building the index before you decide how to run this job.

Set up SSH authentication

Before borg can connect, you need to add its public key to the Storage Box. Copy the key shown in the AIO interface, then on your VPS:

nano authorized_keys

Paste the key and save. Then copy it to the Storage Box:

ssh -p 23 YOURBOXID-subX@YOURBOXID-subX.your-storagebox.de 'mkdir -p ~/.ssh'
scp -P 23 authorized_keys YOURBOXID-subX@YOURBOXID-subX.your-storagebox.de:.ssh/authorized_keys

Now borg should be able to connect.

Exclude the data directory

To prevent borg from backing up all user files (which would be slow and redundant), add an exclusion marker:

touch /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/.noaiobackup

The borg backup covers Nextcloud configuration and database, but for comprehensive data protection:

  • VPS snapshots: Enable automatic snapshots in Hetzner Console for your VPS. This protects your encryption keys and local data.
  • Storage Box snapshots: Enable automatic snapshots on your Storage Box. This protects your (encrypted) user files.

Together with borg backup, this gives you:

Data Protected by
Nextcloud config & database Borg backup
Encryption keys VPS snapshots
User files (encrypted) Storage Box snapshots

A note on backup strategy

With ransomware in mind, consider keeping an additional offline or off-site copy. If you have a Synology or similar NAS, you can sync via WebDAV (find the link in Nextcloud's Files app → Settings → WebDAV) and make local versioned backups with Hyper Backup.

Step 13 - Staying on top of updates

The guide so far gets Nextcloud running; this step keeps it healthy over months without you having to remember to log in and check. One fact about our setup shapes the whole update strategy: the disk is LUKS-encrypted and needs a passphrase typed at boot via Dropbear. So an automatic reboot is off the table — it would leave the server stuck at the unlock prompt, offline, until you SSH in to unlock it.

That leads to a three-part split:

Update type Handling
Security updates Auto-installed by unattended-upgrades
Reboot to activate them Manual — a Nextcloud notification tells you when one is pending
General (non-security) updates Notify only — a ~3-weekly Nextcloud digest; you apply when convenient

Everything reports as a Nextcloud notification to your admin user — no extra channel to watch, it shows up in the app you already use. The whole mechanism is one command, which each script below reuses:

docker exec --user www-data nextcloud-aio-nextcloud \
  php occ notification:generate admin "Subject line" -l "Longer detail text"

Note: This is about the host OS and third-party apt packages (like Docker). Nextcloud itself — the containers, the database, PHP — is updated by AIO's own daily backup/update process (Step 12), not by anything here.

Throughout this step, replace admin with the Nextcloud username you want notified.

Security updates: confirm unattended-upgrades

Debian/Ubuntu can install security updates on their own. Make sure it's set up, and pull in needrestart (the reboot watcher below uses it as its best signal):

apt install unattended-upgrades needrestart
dpkg-reconfigure -plow unattended-upgrades   # answer "Yes" to enable automatic updates

Then confirm automatic reboots stay off — check /etc/apt/apt.conf.d/50unattended-upgrades contains:

Unattended-Upgrade::Automatic-Reboot "false";

Remove // at the beginning of the line so that it actually applies.

This is the deliberate part: security updates get installed automatically, but anything needing a reboot (kernel, glibc, systemd) stays inactive until you reboot and unlock manually. The next piece makes sure you find out when that's the case — for kernel updates by default, and for the rest only if you install update-notifier-common, as its coverage note explains.

Notify when a reboot is pending

Create the watcher script:

nano /usr/local/bin/nc-reboot-watch.sh
#!/bin/bash
# nc-reboot-watch.sh - Nextcloud notification when a reboot is pending (e.g. after
# unattended-upgrades installed a new kernel). See the coverage note below - in practice
# this is a KERNEL reboot watcher.
#
# WHY: this box uses LUKS full-disk encryption with a MANUAL passphrase at boot via
# Dropbear, so unattended-upgrades has Automatic-Reboot OFF (a surprise auto-reboot
# would leave the box stuck at the LUKS prompt, offline, until someone unlocks it).
# Consequence: reboot-requiring updates get INSTALLED but stay INACTIVE until a
# deliberate, unlock-ready reboot. This watcher makes sure you know one is pending.
#
# Detection (first hit wins):
#   1. needrestart -b -r l : NEEDRESTART-KSTA >= 2  (kernel reboot needed; best on Debian)
#   2. /run/reboot-required exists                  (non-kernel; needs update-notifier-common)
#   3. running kernel (uname -r) != newest installed /boot/vmlinuz-*  (fallback)
#
# COVERAGE: methods 1 and 3 are kernel-only, so on a stock Debian box this watcher tells you
# about kernel updates and nothing else. Updates that need a reboot for other reasons - glibc,
# systemd - are only caught by method 2, and /run/reboot-required is written by
# update-notifier-common, which is not installed by default here. Install it if you want that
# coverage; otherwise read a quiet watcher as "no new kernel", not as "nothing pending".
# needrestart also reports services needing a restart (NEEDRESTART-SVC), which this does not
# parse - restarting a service is not a reboot, and unattended-upgrades handles it.
#
# Notify on first detection + a daily re-nag while still pending; clears once rebooted.
# Runs from a systemd timer (nc-reboot-watch.timer).
set -u
HOST="$(hostname -s)"
CT="nextcloud-aio-nextcloud"
NOTIFY_USERS="admin"                             # space-separated Nextcloud usernames
STATE="/var/lib/nc-reboot-watch/last-notified"   # holds YYYY-MM-DD of last notice
mkdir -p "$(dirname "$STATE")"

occ(){ timeout 30 docker exec --user www-data "$CT" php occ "$@" 2>/dev/null; }
notify(){ local s="$1" l="$2" u; for u in $NOTIFY_USERS; do occ notification:generate "$u" "$s" -l "$l"; done; }

pending=""; reason=""

# 1. needrestart (report-only: -r l never restarts anything)
if command -v needrestart >/dev/null 2>&1; then
  nr=$(needrestart -b -r l 2>/dev/null)
  ksta=$(printf '%s\n' "$nr" | awk -F': ' '/NEEDRESTART-KSTA/{print $2}')
  kcur=$(printf '%s\n' "$nr" | awk -F': ' '/NEEDRESTART-KCUR/{print $2}')
  kexp=$(printf '%s\n' "$nr" | awk -F': ' '/NEEDRESTART-KEXP/{print $2}')
  if [ -n "${ksta:-}" ] && [ "$ksta" -ge 2 ] 2>/dev/null; then
    pending=yes; reason="kernel ${kcur:-?} -> ${kexp:-newer}"
  fi
fi

# 2. /run/reboot-required (non-kernel)
if [ -z "$pending" ] && [ -f /run/reboot-required ]; then
  pending=yes
  pk=$(tr '\n' ' ' < /run/reboot-required.pkgs 2>/dev/null)
  reason="packages: ${pk:-see /run/reboot-required}"
fi

# 3. running-vs-installed kernel fallback (no needrestart)
if [ -z "$pending" ]; then
  run=$(uname -r)
  newest=$(ls -1 /boot/vmlinuz-* 2>/dev/null | sed 's#.*/vmlinuz-##' | sort -V | tail -1)
  if [ -n "$newest" ] && [ "$newest" != "$run" ]; then
    pending=yes; reason="kernel $run -> $newest"
  fi
fi

today=$(date +%F)
last=$(cat "$STATE" 2>/dev/null || echo "")

if [ -n "$pending" ]; then
  if [ "$last" != "$today" ]; then
    notify "$HOST: reboot pending" "Updates need a reboot to activate ($reason). Auto-reboot is OFF (LUKS/Dropbear), so this stays inactive until you reboot manually and unlock via Dropbear. When ready: run 'sudo reboot', then SSH to Dropbear to enter the passphrase. Detail on the box: 'needrestart' or 'cat /run/reboot-required.pkgs'."
    echo "$today" > "$STATE"
    echo "PENDING ($reason) - notified"
  else
    echo "PENDING ($reason) - already notified today"
  fi
else
  rm -f "$STATE"
  echo "no reboot pending"
fi

Make it executable and add a systemd service + daily timer:

chmod +x /usr/local/bin/nc-reboot-watch.sh
nano /etc/systemd/system/nc-reboot-watch.service
[Unit]
Description=Notify (Nextcloud) when a kernel reboot is pending
After=docker.service
Wants=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/nc-reboot-watch.sh
nano /etc/systemd/system/nc-reboot-watch.timer
[Unit]
Description=Daily check for a pending reboot (after unattended-upgrades runs)
# 08:00 - after the apt-daily-upgrade timer (~06:35) has applied overnight updates.

[Timer]
OnCalendar=*-*-* 08:00:00
RandomizedDelaySec=10min
Persistent=true

[Install]
WantedBy=timers.target

Enable it:

systemctl daemon-reload
systemctl enable --now nc-reboot-watch.timer

Persistent=true means that if the box was off at 08:00, the check runs at the next boot instead of being skipped.

General updates: a ~3-weekly digest

Security updates auto-apply, but general updates — and notably third-party apt repos like Docker's, which unattended-upgrades never touches — need a human to decide when to apply them. Rather than nag you constantly, this script pushes a single digest of what's upgradable roughly every three weeks, and only when there's actually something to install.

nano /usr/local/bin/nc-update-digest.sh
#!/bin/bash
# nc-update-digest.sh - periodic Nextcloud digest of AVAILABLE package updates across
# ALL repos (Debian stable-updates + third-party like Docker's apt repo). SECURITY
# updates are auto-applied by unattended-upgrades and are NOT what this is about - this
# is the "here are the general updates to review and apply when you feel like it" nudge.
#
# Runs from a weekly timer but only PUSHES every INTERVAL_DAYS (default 21 = every 3
# weeks), and only when there is actually something upgradable. Survives downtime (the
# gate is wall-clock via a state timestamp, and the timer is Persistent).
# Override cadence in /etc/nc-update-digest.conf (INTERVAL_DAYS=).
set -u
HOST="$(hostname -s)"
CT="nextcloud-aio-nextcloud"
NOTIFY_USERS="admin"                             # space-separated Nextcloud usernames
INTERVAL_DAYS=21
STATE="/var/lib/nc-update-digest/last-push"      # epoch seconds of last push
mkdir -p "$(dirname "$STATE")"
[ -r /etc/nc-update-digest.conf ] && . /etc/nc-update-digest.conf

occ(){ timeout 30 docker exec --user www-data "$CT" php occ "$@" 2>/dev/null; }
notify(){ local s="$1" l="$2" u; for u in $NOTIFY_USERS; do occ notification:generate "$u" "$s" -l "$l"; done; }

now=$(date +%s)
last=$(cat "$STATE" 2>/dev/null || echo 0)
days=$(( (now - last) / 86400 ))
if [ "$days" -lt "$INTERVAL_DAYS" ]; then
  echo "not due (${days}d < ${INTERVAL_DAYS}d since last push)"; exit 0
fi

apt-get update -qq 2>/dev/null || true          # best-effort refresh; ignore apt lock
mapfile -t up < <(apt list --upgradable 2>/dev/null | sed '1d')   # drop "Listing..." header
count=${#up[@]}

if [ "$count" -eq 0 ]; then
  echo "due, but nothing upgradable - not pushing (clock NOT reset; re-checks next week)"
  exit 0
fi

names=$(printf '%s\n' "${up[@]}" | awk -F/ '{print $1}' | head -25 | paste -sd', ' -)
[ "$count" -gt 25 ] && names="${names}, +$((count-25)) more"

notify "$HOST: ${count} package update(s) to review" "General (non-security) updates available across all repos - security ones auto-apply separately. Includes: ${names}. Apply when convenient: 'sudo apt update && sudo apt full-upgrade', then check the reboot-pending notice. (Third-party repos like Docker's surface here too.)"
echo "$now" > "$STATE"
echo "PUSHED: ${count} updates"

Service + weekly timer:

chmod +x /usr/local/bin/nc-update-digest.sh
nano /etc/systemd/system/nc-update-digest.service
[Unit]
Description=Push a Nextcloud digest of available package updates (every ~3 weeks)
After=docker.service
Wants=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/nc-update-digest.sh
nano /etc/systemd/system/nc-update-digest.timer
[Unit]
Description=Weekly check that pushes a package-update digest every ~3 weeks
# Fires weekly (Mon 09:00); the script only pushes when >=INTERVAL_DAYS (21) have
# passed since the last push AND there is something upgradable. 21 is a multiple of 7,
# so with a Monday trigger the digest lands every 3 weeks on a Monday.

[Timer]
OnCalendar=Mon *-*-* 09:00:00
RandomizedDelaySec=10min
Persistent=true

[Install]
WantedBy=timers.target

Enable it:

systemctl daemon-reload
systemctl enable --now nc-update-digest.timer

Prefer a different cadence? Drop an override in /etc/nc-update-digest.conf, e.g. INTERVAL_DAYS=14 for fortnightly.

Test both notifications

You can trigger either script by hand to confirm the Nextcloud notification arrives:

/usr/local/bin/nc-reboot-watch.sh
/usr/local/bin/nc-update-digest.sh

The digest only pushes if it's "due" and something is upgradable — to force a one-off test push regardless, temporarily clear its state file: rm -f /var/lib/nc-update-digest/last-push. Check the timers are scheduled with:

systemctl list-timers 'nc-*'

Step 14 - Final considerations

Expanding storage

Storage Box: Increasing your Storage Box size immediately increases available space for user files. No action needed on the VPS — the SMB mount sees the new capacity automatically.

VPS local disk: If you need more space for the LUKS-encrypted local disk (encryption keys, database, app data), you'll need to:

  1. Shut down the VPS
  2. Resize the disk in Hetzner Console
  3. Boot the VPS normally
  4. Expand the partition and LUKS container:
# Grow the partition (adjust partition number as needed)
apt install cloud-guest-utils   # provides growpart; not in a base Debian install
growpart /dev/sda 2

# Resize the LUKS container to fill available space
cryptsetup resize luks-<your-uuid>

# Resize the ext4 filesystem (can be done live)
resize2fs /dev/mapper/luks-<your-uuid>

Tip: Take a VPS snapshot before resizing, just in case.

Adding new users workflow

When you create a new user in Nextcloud:

  1. The user's data defaults to local LUKS storage (secure by default)
  2. If you want to offload their files to the Storage Box:
    • Create their folders on the Storage Box (Step 10)
    • If they have already logged in once, move their existing files across first (Step 10) — a bind mount hides them otherwise
    • Add fstab entries (Step 10)
    • Add a mountpoint check to nextcloud-mounts-check.service (Step 11)
    • Run systemctl daemon-reload && mount -a

Server-side encryption gotchas

These are traps specific to Nextcloud's Default encryption module. None of them are obvious, and two of them can waste a lot of time.

Verify decryption the way a sync client fetches, never from a browser. There is a class of bug where oc_filecache.encrypted is 0 while the file on disk is genuinely encrypted. The web UI decrypts such a file correctly; /remote.php/dav/… hands out the raw ciphertext. So a browser test says "all clear" while every desktop and mobile client silently downloads unusable blobs. Reproduce it properly with an app password:

curl -s -u 'user:APP_PASSWORD' \
  'https://cloud.example.com/remote.php/dav/files/username/path/to/file' | head -c 6

If that prints HBEGIN, you are serving ciphertext.

Fix that class of bug unscoped. occ encryption:fix-encrypted-version <user> accepts a -p path scope — but a scoped run repairs only that subtree and leaves everything else broken, with no indication that it did so. Run it for the whole user.

unencrypted_size = 0 is normal. It is populated only in some cases. It is not a damage signal, and chasing it as one is a dead end. size is the on-disk (encrypted) size.

oc_filecache.encrypted is a key version, not a boolean. 0 means "not flagged encrypted"; any value ≥ 1 means "encrypted with that key version".

A valid-looking file ending does not prove the file is complete. Encrypted files are a header plus independently signed 8 KB blocks, so a file truncated at a block boundary still ends in a perfectly valid, correctly signed block. Size arithmetic is the reliable check: 8192 + N × 8192 bytes, where each block holds 8096 bytes of plaintext. A file of exactly 8192 bytes is header-only — i.e. empty.

Known upstream bug: with server-side encryption enabled, overwriting an existing file whose name begins with a dot returns HTTP 500 over WebDAV. Creating one works; overwriting a non-dotfile works. This bites sync clients on files like .gitignore. Test whether your version is affected:

printf one > /tmp/x1; printf twotwotwo > /tmp/x2
B=https://cloud.example.com/remote.php/dav/files/username
curl -su "user:APP_PASSWORD" -T /tmp/x1 "$B/.probe" -o /dev/null -w '%{http_code}\n'  # expect 201
curl -su "user:APP_PASSWORD" -T /tmp/x2 "$B/.probe" -o /dev/null -w '%{http_code}\n'  # 500 = affected
curl -su "user:APP_PASSWORD" -X DELETE "$B/.probe" -o /dev/null

Filenames that SMB cannot represent

SMB reserves \ / : * ? " < > |, and the Linux CIFS client maps them into the Unicode Private Use Area (U+F021U+F03F) rather than rejecting them. Files with such names appear to work but their paths no longer match what Nextcloud recorded — which, with encryption on, means the key path no longer resolves either.

If you are importing an existing collection onto the Storage Box, rename offending files before copying them, not after. Checking for them afterwards:

LC_ALL=C.UTF-8 find /mnt/myshare -depth | LC_ALL=C.UTF-8 grep -P '[\x{F000}-\x{F0FF}]' | head

Cleaning up leftovers

If you find leftover folders on the Storage Box from initial setup (like files_encryption or appdata_*), you can safely remove them — the real data lives on the local disk:

# Check what's there that shouldn't be
ls -la /mnt/myshare/_data/

# Remove leftovers (be careful!)
rm -rf /mnt/myshare/_data/files_encryption
rm -rf /mnt/myshare/_data/appdata_*

Only the per-user folders (admin/, justin/, etc.) should exist on the Storage Box.

Step 15 - Full-text search on Storage Box space (Optional)

Nextcloud's full-text search indexes the contents of your files, not just their names, and that index gets big — often too big for the small LUKS-encrypted VPS disk you are keeping your keys and database on.

How big depends entirely on your data and cannot be predicted from a disk-usage figure: what matters is how many indexable documents you have and how much text is in them. A terabyte of photos and video indexes to almost nothing; a few gigabytes of documents, mail archives and source code indexes to a lot. On the box this step was written from, ~300,000 files produced roughly 15 GB. Treat that as one data point, not a formula — size your container from your own document count, and leave headroom, because growing it later means rebuilding it.

This step puts the Elasticsearch index inside a LUKS container file on the Storage Box — the same key-separation principle as the rest of the guide (the key stays on the VPS), and it costs you none of your scarce local disk. It is entirely optional; skip it if you do not want full-text search.

Check your RAM first. Elasticsearch is a JVM and will be the largest single consumer on the machine — expect ~1 GiB resident. On a 4 GB VPS running the full AIO stack that is roughly a quarter of your memory and it will push you into swap. If memory is tight, this step is also the easiest thing to not do, or to disable later to reclaim a gigabyte in one move.

Read the whole step before starting. Elasticsearch on a network-backed loop device has exactly one configuration that works. Get the direct-io flag wrong and your index will silently corrupt itself under load.

Enable full-text search in AIO

Go to the admin settings at https://example.com/settings/admin/overview and select Open Nextcloud AIO Interface at the top of the page.

AIO only lets you change the optional container selection while the stack is stopped, so: click "Stop containers", tick "Full text search", then click "Start containers" again. That start is what actually creates the nextcloud_aio_elasticsearch Docker volume and the nextcloud-aio-fulltextsearch container — the mount units below bind onto that volume's path, so it has to exist before you build them.

Elasticsearch will come up on the local disk for this one start. That is expected. Be aware of what happens to the data it writes there: the bind mount later covers that directory rather than emptying it, so those few files stay on the local disk, hidden and unreachable, until you unmount the bind and delete them. It is a nearly-empty Elasticsearch data directory, so this is tidiness rather than capacity — but it is not "thrown away".

Then click "Stop containers" once more before continuing — the volume must be idle for the next part.

Create the encrypted container on the Storage Box

# A 25 GB container file - size this from your own document count, see above. Do NOT
# use fallocate/truncate: on CIFS they produce a sparse
# file, and a sparse file that cannot actually grow is a corrupt filesystem later.
dd if=/dev/zero of=/mnt/myshare/elasticsearch.luks bs=1M count=25600 status=progress

# Key lives on the LOCAL LUKS disk, never on the Storage Box - this is the key separation
# that the rest of this guide is built around.
mkdir -p /root/keys && chmod 700 /root/keys
dd if=/dev/urandom of=/root/keys/elasticsearch.key bs=512 count=1
chmod 400 /root/keys/elasticsearch.key

cryptsetup luksFormat --key-file /root/keys/elasticsearch.key /mnt/myshare/elasticsearch.luks
cryptsetup open --key-file /root/keys/elasticsearch.key /mnt/myshare/elasticsearch.luks nextcloud_aio_elasticsearch
mkfs.ext4 /dev/mapper/nextcloud_aio_elasticsearch
mkdir -p /mnt/nextcloud_aio_elasticsearch

# Hand the filesystem to the container user. A fresh ext4 belongs to root; the AIO
# fulltextsearch image runs as uid 1000, gid 0 and never chowns its own data directory.
mount /dev/mapper/nextcloud_aio_elasticsearch /mnt/nextcloud_aio_elasticsearch
chown 1000:0 /mnt/nextcloud_aio_elasticsearch
chmod 770 /mnt/nextcloud_aio_elasticsearch
umount /mnt/nextcloud_aio_elasticsearch

# Close it again. From here on the systemd unit below owns the mapping, and it runs the
# same `cryptsetup open` - which fails with "Device already exists" if you leave this open.
cryptsetup close nextcloud_aio_elasticsearch

Do not skip that chown. Elasticsearch starts as uid 1000 and its very first write is node.lock, so a root-owned data directory fails immediately and permanently:

failed to obtain node locks
AccessDeniedException: /usr/share/elasticsearch/data/node.lock

This is easy to miss because a normal AIO install never needs it: Docker chowns a newly created, still empty named volume to the owner the image expects. Here the volume is a mounted ext4 that already contains lost+found, so Docker sees a non-empty volume and leaves ownership alone. The chown is set once, in the filesystem — it is not something to repeat at each boot.

Unlock unit — and the one flag that matters

cryptsetup open on a file automatically creates a loop device, and loop devices default to buffered I/O on their backing file. With the backing file on CIFS, every Elasticsearch write is then cached twice — by the ext4 above and by CIFS below — and writes can be lost or reordered. Lucene detects this as codec header mismatch and the shard dies:

CorruptIndexException: codec header mismatch: actual header=-1686713664 vs expected header=1071082519

The filesystem looks perfectly healthy when this happens (e2fsck clean, no kernel errors) because ext4's metadata is consistent — only the file contents are wrong. ExecStartPost below forces O_DIRECT and is not optional.

nano /etc/systemd/system/crypt-nextcloud_aio_elasticsearch.service
[Unit]
Description=Unlock nextcloud_aio_elasticsearch LUKS container
DefaultDependencies=no
Requires=network-online.target mnt-myshare.mount
After=network-online.target mnt-myshare.mount

[Service]
Type=oneshot
RemainAfterExit=true
ExecStart=/sbin/cryptsetup open --key-file /root/keys/elasticsearch.key /mnt/myshare/elasticsearch.luks nextcloud_aio_elasticsearch
# Force O_DIRECT on the auto-created loop device. Looked up BY BACKING FILE because
# /dev/loopN is not stable across boots. Without this, the index corrupts under load.
ExecStartPost=/bin/sh -c 'losetup -j /mnt/myshare/elasticsearch.luks -O NAME -n | xargs -r losetup --direct-io=on'
ExecStop=/sbin/cryptsetup close nextcloud_aio_elasticsearch
TimeoutSec=30
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target

Mount units

The decrypted ext4 is mounted once at /mnt/…, then bind-mounted into the path AIO's docker volume actually reads. Both are needed: docker must be gated on the bind, or a boot can start Elasticsearch against an empty local directory and it will happily build a fresh, empty index.

nano /etc/systemd/system/mnt-nextcloud_aio_elasticsearch.mount
[Unit]
Description=Mount decrypted nextcloud_aio_elasticsearch volume
DefaultDependencies=no
Requires=crypt-nextcloud_aio_elasticsearch.service
After=crypt-nextcloud_aio_elasticsearch.service

[Mount]
What=/dev/mapper/nextcloud_aio_elasticsearch
Where=/mnt/nextcloud_aio_elasticsearch
Type=ext4
Options=noatime
TimeoutSec=130

[Install]
WantedBy=multi-user.target
nano /etc/systemd/system/var-lib-docker-volumes-nextcloud_aio_elasticsearch-_data.mount
[Unit]
Description=Bind decrypted ES volume into the AIO docker _data path
DefaultDependencies=no
Requires=mnt-nextcloud_aio_elasticsearch.mount
After=mnt-nextcloud_aio_elasticsearch.mount

[Mount]
What=/mnt/nextcloud_aio_elasticsearch
Where=/var/lib/docker/volumes/nextcloud_aio_elasticsearch/_data
Type=none
Options=bind
TimeoutSec=30

[Install]
WantedBy=multi-user.target

The unit filename must match the escaped path exactly. Verify with:

systemd-escape -p --suffix=mount /var/lib/docker/volumes/nextcloud_aio_elasticsearch/_data

Gate Docker on both mounts

Extend the override you created in Step 11 so Docker will not start until the encrypted volume is in place:

nano /etc/systemd/system/docker.service.d/override.conf
[Unit]
Requires=mnt-myshare.mount nextcloud-mounts-check.service mnt-nextcloud_aio_elasticsearch.mount var-lib-docker-volumes-nextcloud_aio_elasticsearch-_data.mount
After=mnt-myshare.mount nextcloud-mounts-check.service mnt-nextcloud_aio_elasticsearch.mount var-lib-docker-volumes-nextcloud_aio_elasticsearch-_data.mount

Two shutdown problems you will hit otherwise

  1. blkdeactivate force-unmounts the volume mid-shutdown. The stock blk-availability.service (from lvm2) has DefaultDependencies=no, no ordering against Docker, and an ExecStop of blkdeactivate -u — a force unmount of everything device-mapper backed. It can fire one second after Docker merely begins stopping, pulling the volume out from under a running Elasticsearch. cryptsetup close then fails with "still in use", the loop device cannot detach, the Storage Box mount cannot unmount, and shutdown loops on Device or resource busy until the hardware watchdog resets the machine.

    mkdir -p /etc/systemd/system/blk-availability.service.d
    nano /etc/systemd/system/blk-availability.service.d/order-after-docker.conf
    [Unit]
    # Before= means it STARTS before these and therefore STOPS after them (systemd stops in
    # reverse start order), so by the time blkdeactivate runs the stack is already torn down.
    Before=docker.service containerd.service \
           var-lib-docker-volumes-nextcloud_aio_elasticsearch-_data.mount \
           mnt-nextcloud_aio_elasticsearch.mount \
           crypt-nextcloud_aio_elasticsearch.service \
           mnt-myshare.mount
  2. Docker kills Elasticsearch too early. dockerd allows 15 s between SIGTERM and SIGKILL. A killed Elasticsearch leaves the ext4 dirty. Raise it in /etc/docker/daemon.json (merge with what is already there):

    {
        "shutdown-timeout": 90
    }

    Do not write a "clean shutdown" unit that runs docker stop. It is the obvious idea and it backfires: Docker records that as an explicit user stop, and the container's unless-stopped restart policy then deliberately refuses to start it at the next boot. The ordering above is sufficient — a container stopped because dockerd itself shut down is not flagged as user-stopped, so it comes back on its own.

Enable everything and verify

systemctl daemon-reload
systemctl enable --now crypt-nextcloud_aio_elasticsearch.service
systemctl enable --now mnt-nextcloud_aio_elasticsearch.mount
systemctl enable --now var-lib-docker-volumes-nextcloud_aio_elasticsearch-_data.mount
systemctl restart docker

Then click "Start containers" in the AIO interface and check:

# MUST show DIO = 1. If it shows 0, stop and fix it before indexing anything.
losetup -l

# Both active, and the ext4 visible at the docker volume path
systemctl is-active mnt-nextcloud_aio_elasticsearch.mount var-lib-docker-volumes-nextcloud_aio_elasticsearch-_data.mount
df -h /var/lib/docker/volumes/nextcloud_aio_elasticsearch/_data

# The volume Elasticsearch will write into: must print `1000 0`.
ls -ldn /var/lib/docker/volumes/nextcloud_aio_elasticsearch/_data

Reboot once now and re-check losetup -l, both mounts, and that the container came back by itself. Verifying this before you spend days building an index is worth the two minutes.

After reboot, remember to connect to port 2222 as root and unlock the encrypted partition via cryptroot-unlock. Then connect to your user and run sudo su to get back into root.

Building the index

occ() { docker exec --user www-data nextcloud-aio-nextcloud php occ "$@"; }

occ fulltextsearch:test    # round-trips real documents through Elasticsearch; all lines must say ok
occ fulltextsearch:index

Practical notes, all learned the hard way:

  • It takes a long time. With O_DIRECT (which removes the write cache — the price of not corrupting) expect roughly 2,700 documents/hour — so 300,000 documents would be about 4-5 days. Run it in tmux or screen.
  • There is no resume daemon. occ fulltextsearch:index is a plain foreground command. A reboot, a dropped SSH session or a container restart kills it. It does pick up roughly where it left off when restarted, so progress is not lost — but nothing restarts it for you.
  • Turn the AIO daily backup off for the duration. Its nightly container stop/start kills the run.
  • Ctrl-C does not stop it. docker exec without -t only detaches the client; the PHP process keeps indexing inside the container, and a second run then fails with "Index is already running". Kill it by PID: pkill -f "occ fulltextsearch:index".
  • To start over, reset both sides. Deleting the Elasticsearch data alone leaves Nextcloud's own bookkeeping intact, and the next run then skips documents it believes are already indexed — producing a half-empty index that looks healthy. Use occ fulltextsearch:reset, which asks twice (a y/N prompt and a literal reset ALL ALL phrase, so it cannot be driven by -n or yes).
  • A known upstream bug can abort a long run. fulltextsearch_elasticsearch imports PlatformTemporaryException from OCP\ while the class only exists under OCA\, so the code path meant to say "temporary, retry" instead dies with Class ... not found on any transient Elasticsearch hiccup (issue #513). If a multi-day run matters to you, wrap it in a loop that restarts it.

AIO's backup will include this volume, every night — leave it that way. nextcloud_aio_elasticsearch is in AIO's default backup set, the .noaiobackup marker from Step 12 covers only the data directory, and AIO offers no way to exclude a single volume. So from the moment the daily backup runs again, borg reads the whole index back through the O_DIRECT loop device and writes it to the same Storage Box: hours of work, and Storage Box usage inflated by roughly the index size, which is what Step 16's guard is watching.

Knowing that, the right call is still to keep the daily backup on and accept the cost. That job is also what updates Nextcloud (Step 12) — switching it off to dodge the index would stop your container, database and PHP updates along with your config and database backups. Paying a fat nightly read is much the smaller price, and the index being rebuildable does not help you, because you cannot exclude it.

The one time to turn it off is for the duration of an indexing run, where its container stop/start would kill the run outright. Turn it back on when the run finishes.

A yellow cluster is normal. The index is created asking for one replica, and a single node can never allocate it. Yellow means "one unassignable replica", not degraded data. Only red is a problem.

Step 16 - Guarding against a full or disconnected Storage Box

A hard mount (Step 4) protects you from truncated writes during an outage, but it does not protect you from two other things:

  • The Storage Box filling up. Nextcloud keeps accepting writes until the filesystem refuses them, and a write that fails partway through an encrypted file is not recoverable.
  • The Storage Box disappearing. With a hard mount, php-fpm workers block in uninterruptible sleep and pile up. The site becomes unusable either way; the question is whether it fails safely.

The answer to both is the same: put Nextcloud into maintenance mode, which stops writes at the application layer, and tell someone. This step adds a small guard that does it automatically.

Why not just alert? Because the window between "storage is gone" and "a client is mid-upload" is however long you take to read the alert. Maintenance mode is cheap to undo and expensive to skip.

The script

nano /usr/local/sbin/nc-space-guard.sh
#!/bin/bash
# nc-space-guard.sh - notify on low disk space, and stop writes before they corrupt data.
#
# Per-mount flags:
#   guard yes = auto-enable maintenance mode at CRIT (full) AND when the mount goes
#               UNAVAILABLE (df fails twice a few seconds apart). Latched: it never
#               auto-disables - recovery is a deliberate `maintenance:mode --off`.
#         no  = notify only (for storage where "full" is not a corruption risk)
set -u
CT="nextcloud-aio-nextcloud"
WARN=85            # % used -> notify
CRIT=98            # % used -> notify (+ maintenance mode if guard=yes)
NOTIFY_USERS="admin"
STATE_DIR="/run/nc-space-guard"; mkdir -p "$STATE_DIR"

occ(){ timeout 30 docker exec --user www-data "$CT" php occ "$@" 2>/dev/null; }
notify(){ local s="$1" l="$2" u; for u in $NOTIFY_USERS; do occ notification:generate "$u" "$s" -l "$l"; done; }
sev(){ case "$1" in gone) echo 3;; crit) echo 2;; warn) echo 1;; *) echo 0;; esac; }

# The `timeout 30` on occ above matters for the same reason the one below does: this is what
# runs `maintenance:mode --on`, and PHP touching a hung mount during bootstrap would block it.
#
# usage%, empty on failure/timeout. The timeout is essential: on a network mount a server
# stuck mid-reconnect can block df indefinitely, which would freeze this script during
# exactly the outage it exists to catch (and systemd will not overlap runs).
probe(){ timeout 15 df --output=pcent "$1" 2>/dev/null | tail -1 | tr -dc '0-9'; }

# check <mountpoint> <label> [guard=yes|no]
check(){
  local mount="$1" label="$2" guard="${3:-yes}"
  local st="$STATE_DIR/$(printf '%s' "$label" | tr -c 'A-Za-z0-9' _)"
  local use avail last tier title msg reason

  use=$(probe "$mount")
  # Re-confirm within the same run: one failed df can be a blip rather than an outage.
  # Probing again a few seconds later means maintenance mode engages within seconds of a
  # genuine disconnect instead of a whole timer interval later, while still rejecting a one-off.
  if [ -z "$use" ]; then sleep 5; use=$(probe "$mount"); fi
  avail=$(timeout 15 df -h --output=avail "$mount" 2>/dev/null | tail -1 | tr -d ' ')
  last=$(cat "$st" 2>/dev/null || echo ok)

  if   [ -z "$use" ];            then tier=gone
  elif [ "$use" -ge "$CRIT" ];   then tier=crit
  elif [ "$use" -ge "$WARN" ];   then tier=warn
  else                                tier=ok; fi

  # Only notify when things get worse, so a full disk does not nag every 5 minutes.
  if [ "$(sev "$tier")" -gt "$(sev "$last")" ]; then
    case "$tier" in
      gone) title="$label UNAVAILABLE"
            msg="df failed twice for $mount - storage disconnected. Enabling maintenance mode to stop writes." ;;
      crit) title="$label CRITICALLY full (${use}%)"
            if [ "$guard" = yes ]; then msg="Free ${avail}. Enabling maintenance mode to stop writes."
            else msg="Free ${avail}. Free space urgently."; fi ;;
      warn) title="$label filling up (${use}%)"
            msg="Free ${avail}. Expand or clear space before ${CRIT}%." ;;
    esac
    notify "$title" "$msg"
  fi

  # Latched auto-enable: once per episode, cleared when the mount is healthy again.
  if [ "$guard" = yes ] && [ ! -e "$st.maint" ] \
     && { [ "$tier" = crit ] || [ "$tier" = gone ]; }; then
    case "$tier" in gone) reason="unreachable";; crit) reason="critically full";; esac
    if occ maintenance:mode --on; then
      : > "$st.maint"; rm -f "$st.mfail"
      notify "$label maintenance mode ON" \
        "Writes stopped - $label $reason. After fixing: docker exec --user www-data $CT php occ maintenance:mode --off"
    elif [ ! -e "$st.mfail" ]; then
      # Failing to stop writes is the worst outcome this script has, so say so loudly -
      # and only once. occ failing usually means Nextcloud itself is down.
      : > "$st.mfail"
      notify "$label maintenance-mode enable FAILED" \
        "$label $reason and occ failed (Nextcloud down?). Enable manually: docker exec --user www-data $CT php occ maintenance:mode --on"
    fi
  fi

  case "$tier" in ok|warn) rm -f "$st.maint" "$st.mfail" ;; esac
  echo "$label: ${use:-NA}% used, ${avail:-NA} free -> $tier"
  printf '%s' "$tier" > "$st"
}

check /mnt/myshare "Storage Box" yes
check /            "Local disk"  yes
# If you followed Step 15, watch the search index too - but notify only: a full index
# volume degrades search, it does not corrupt user files.
# check /mnt/nextcloud_aio_elasticsearch "Elasticsearch" no
chmod +x /usr/local/sbin/nc-space-guard.sh

Know this limitation before you rely on it: maintenance mode blocks Nextcloud's app-provided occ commands, including notification:generate. So the "maintenance mode ON" notification will not be delivered through Nextcloud — the site is down, which is precisely when you cannot read a Nextcloud notification. The earlier warnings (85%, 98%, unavailable) do arrive, because they are sent before the flip. If you want to be told at the moment writes stop, you need a second channel that does not depend on Nextcloud.

Optional: a second notification channel

Any service that accepts a webhook works — email, ntfy, Gotify, a chat webhook. One that is particularly quick to set up is Brrr, which gives you a push URL and needs no server of your own. Two additions to the script:

# Put the secret in a root-only config file rather than in the script:
#   /etc/nc-space-guard.conf   (chmod 600)
#   BRRR_URL="https://api.brrr.now/v1/YOUR_SECRET"
[ -r /etc/nc-space-guard.conf ] && . /etc/nc-space-guard.conf

# brrr <title> <message> <level>  -- silently does nothing if BRRR_URL is unset.
# Note: title and message must not contain double quotes or backslashes.
brrr(){
  [ -n "${BRRR_URL:-}" ] || return 0
  curl -fsS -m 10 -X POST "$BRRR_URL" -H 'Content-Type: application/json' \
    --data "{\"title\":\"$1\",\"message\":\"$2\",\"interruption_level\":\"$3\",\"sound\":\"default\"}" >/dev/null 2>&1 || true
}

Put the config line and the brrr() function near the top of the script, next to the existing occ() and notify() helpers.

Then add a brrr call after each existing notify call, passing it the same two variables that notify was given. There are three, and the one that matters most is the maintenance-mode announcement — Nextcloud's own notifications are blocked while maintenance mode is on, so that alert can only reach you through this channel:

notify "$title" "$msg"
brrr "$title" "$msg" "time-sensitive"

notify "$label maintenance mode ON" "$mm"
brrr "$label maintenance mode ON" "$mm" "critical"

notify "$label maintenance-mode enable FAILED" "$mf"
brrr "$label maintenance-mode enable FAILED" "$mf" "critical"

Pass the variables, not a copy of the text — $mm and $mf are built a few lines above each call and already contain the recovery command.

The || true and the empty-URL check are deliberate: a notification channel that is down must never take the guard down with it.

The timer

nano /etc/systemd/system/nc-space-guard.service
[Unit]
Description=Check storage capacity and availability for Nextcloud

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/nc-space-guard.sh
# `Type=oneshot` DISABLES the start timeout by default, so without this a blocked run hangs
# indefinitely and systemd will not overlap activations. Insurance against a wedged dockerd
# rather than a failure anyone has hit — occ *failing* is already handled below.
#
# Size it ABOVE the script's own worst case or it will kill a slow escalation halfway
# through instead of catching a wedge. Per check(): probe 15 + sleep 5 + re-probe 15 +
# avail 15 + tier notify + maintenance:mode 30 + maintenance notify. Each notify() is one
# occ call PER USER in NOTIFY_USERS, so two users make each notify 60s, giving ~200s per
# guarded mount. Multiply by your mounts, add headroom, and recompute if you add users.
TimeoutStartSec=600
nano /etc/systemd/system/nc-space-guard.timer
[Unit]
Description=Run nc-space-guard every 5 minutes

[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
Persistent=true

[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now nc-space-guard.timer

Test it

# Run it by hand - should print one line per mount, all "ok"
/usr/local/sbin/nc-space-guard.sh

# Confirm the timer is scheduled
systemctl list-timers nc-space-guard.timer

To test the notification path without filling anything up, temporarily set WARN=0 in the script, run it by hand, and check that the notification arrives in Nextcloud. Put it back afterwards, and clear the state so it re-arms:

rm -f /run/nc-space-guard/*

Recovery is deliberate. The guard never turns maintenance mode back off. Once you have fixed the cause — freed space, or restored the mount — run:

docker exec --user www-data nextcloud-aio-nextcloud php occ maintenance:mode --off

That is intentional: automatic recovery would let a flapping mount toggle your site on and off, writing during each window it is up.

Changelog

This guide has been through several structural revisions. If you followed an earlier version, these are the changes that matter.

Retrofitting an existing installation

If you already built this and want the fixes without rebuilding, do them in this order. Items 1 and 2 change configuration and need a reboot — do both, then reboot once. Item 1 is the urgent one: leaving it alone keeps risking new damage. Items 3 to 7 are read-only checks for damage that may already have happened; none of them touch the running system.

  1. Check whether your SMB mount is soft — it probably is.

    Earlier versions of this guide omitted hard, and mount.cifs defaults to soft. Read what is actually in force rather than what the unit file says, because CIFS will not switch between the two on mount -o remount, so an edited unit can sit there for months without taking effect:

    findmnt -no OPTIONS /mnt/myshare | tr ',' '\n' | grep -E '^(hard|soft)$'

    No output at all also means soft — the default is simply not listed. To fix it, add hard to Options= in /etc/systemd/system/mnt-myshare.mount (see Step 4), then:

    systemctl daemon-reload

    Do not reboot yet — item 2 needs one too. A reboot is the honest way to apply this: remounting by hand means releasing every bind mount and stopping Docker first, and getting that half-right is how you end up running files:scan against empty directories. Do item 2 first, then reboot once, then verify with the same findmnt command.

  2. Add the mount ordering — your bind mounts almost certainly lack it.

    Every version of this guide before this one wrote the fstab lines without x-systemd.before=nextcloud-mounts-check.service, so nextcloud-mounts-check.service races the mounts it verifies on every boot. Lose the race and Docker does not start at all — see Step 11 for why. It is intermittent, so a run of successful reboots is not evidence you are unaffected.

    grep -c 'x-systemd.before=nextcloud-mounts-check.service' /etc/fstab

    That should equal your total number of bind lines: four per offloaded user (files, files_trashbin, files_versions, uploads), so six offloaded users means 24. Compare it against grep -c '/_data/' /etc/fstab. Users you never offloaded have no bind lines and need nothing here. If the count is 0, add the option to every bind line as shown in Step 10, then:

    systemctl daemon-reload

    Then reboot — once, covering this and item 1:

    reboot

    After it comes back, verify from the mount side with the systemd-escape command in Step 11; checking the service under-reports.

  3. Check whether anything was damaged while the mount was soft.

    A soft mount truncates files if the Storage Box was ever unreachable mid-write. If your sync clients have reported errors, or files fail to open, see Server-side encryption gotchas in Step 14 — in particular that a file ending in a valid signed block can still be truncated, so size arithmetic rather than a trailing-bytes check is the reliable test. Files damaged this way are not repairable; restore them from backup.

  4. Check whether a bind mount is hiding your users' skeleton folders.

    The main path of Step 10 used to mount an empty Storage Box directory straight over a user's files/, which already held the skeleton Nextcloud copies there at first login (Documents/, Photos/, Templates/, a few PDFs). Moving the content first was described further down the step, as an aside for users who "already have files" — easy to read as not applying to a fresh install, when in fact it applies to admin on every one. The rows stay in oc_filecache while the files sit hidden underneath the mount, so the folders are listed in the web UI but creating a document or uploading into them fails. Any user who logged in before you set their bind mounts up is affected the same way.

    ls /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/files

    If that comes back empty while the web UI still shows Documents/ and Photos/, follow Already mounted over the files? (recovery) in Step 10. Nothing is lost — the content is underneath the mount, not deleted.

  5. Check what your sync clients actually receive.

    Independently of anything above: there is a Nextcloud bug where oc_filecache.encrypted is 0 for a file that is genuinely encrypted on disk. The web UI decrypts it correctly and WebDAV hands out raw ciphertext, so a browser test tells you nothing. Probe it the way a desktop client fetches, using an app password — the command and the rest of the traps are in Server-side encryption gotchas in Step 14:

    curl -s -u 'user:APP_PASSWORD' \
      'https://cloud.example.com/remote.php/dav/files/username/path/to/file' | head -c 6

    HBEGIN means you are serving ciphertext. Repair it with occ encryption:fix-encrypted-version <user>, unscoped — a -p run silently fixes only that subtree.

  6. Check for filenames SMB cannot represent.

    Only relevant if you imported an existing collection onto the Storage Box rather than uploading through Nextcloud. SMB reserves \ / : * ? " < > |, and the Linux CIFS client maps them into the Unicode Private Use Area instead of rejecting them — the file appears to work while its path no longer matches what Nextcloud recorded, which with encryption on means the key path stops resolving too:

    LC_ALL=C.UTF-8 find /mnt/myshare -depth | LC_ALL=C.UTF-8 grep -P '[\x{F000}-\x{F0FF}]' | head

    Any hit needs renaming; see Filenames that SMB cannot represent in Step 14.

  7. Check what your firewall actually covers.

    If you have added containers of your own since, confirm none of them publish a port you assumed ufw was blocking:

    docker ps --format '{{.Names}}\t{{.Ports}}'

    Anything showing 0.0.0.0:PORT-> is reachable from the internet regardless of your ufw rules. Bind it to 127.0.0.1 instead.

  8. Optional additions.

    Step 13 (update and reboot notifications), Step 15 (encrypted Elasticsearch volume) and Step 16 (the capacity and availability guard) are self-contained — follow them as written on a running system. Step 13 is the one to do first if you have been running this box without knowing when it needs a reboot; full-disk encryption means it cannot reboot itself, so a pending kernel update simply sits there. Step 16 pairs with the hard mount from item 1: together they cover both "storage is gone" and "storage is full", which is why they are worth having as a pair.

  9. Still on the original layout?

    If your NEXTCLOUD_DATADIR still points at the Storage Box, your encryption keys are sitting next to your encrypted files and none of the above matters as much as fixing that. See Migration from Previous Guide below.

Iteration 3 (current) - Optional encrypted Elasticsearch volume

  • Added Step 15: optional full-text search with the Elasticsearch index in a LUKS container on the Storage Box, so the index does not have to fit on the small local disk. Same key-separation model — the key stays on the VPS.
  • The decrypted volume must be chown 1000:0 before Elasticsearch first starts, or it dies on node.lock with AccessDeniedException. Docker's automatic chown of a new named volume does not apply here, because the mounted ext4 is already non-empty (lost+found).
  • Documented the failure that makes or breaks this: a loop device over a network filesystem defaults to buffered I/O, which silently corrupts a Lucene index under sustained write load. losetup --direct-io=on is mandatory, and losetup -l showing DIO 1 is the thing to check after every reboot.
  • Documented two shutdown hazards for anyone stacking LUKS on a network mount: blk-availability.service force-unmounting device-mapper volumes out of order, and dockerd's 15-second kill timeout being too short for a large Elasticsearch to flush.
  • Noted why a shutdown unit that calls docker stop is counterproductive: it defeats the container's own unless-stopped restart policy.
  • Added hard to the SMB mount options. mount.cifs defaults to soft, which returns errors mid-write during a Storage Box outage and leaves truncated files — unrecoverable once server-side encryption is on. This is a correctness fix; earlier versions of this guide produced soft mounts.
  • Noted that ufw does not block ports published by bridge-network containers, because Docker's own iptables rules are consulted first.
  • Added a server-side encryption gotchas section: encrypted=0 leaking over WebDAV while the browser decrypts correctly, encryption:fix-encrypted-version needing to be run unscoped, unencrypted_size=0 being normal, why a valid block trailer does not prove a file is complete, and the dotfile-overwrite HTTP 500.
  • Added a note on SMB reserved characters being mapped into the Unicode Private Use Area, which breaks encryption key paths if files are imported without renaming first.
  • Warned that files:scan purges the filecache when the bind mounts are missing — the directory reads back empty, so the scan records every file as deleted. Attached to Step 11, since that is the check which prevents it.
  • Noted that seal can fail an allocation under memory pressure on a small VPS, producing a client copy error that looks like corruption but is only a failed write.
  • The bind mounts now carry x-systemd.before=nextcloud-mounts-check.service. Without it the Step 11 check races the mounts it verifies: nofail stops systemd ordering those mounts before local-fs.target, so on some boots the check runs first, fails, and takes Docker down with it — intermittently, and with the cause three units from the symptom. Ordering from fstab rather than with RequiresMountsFor= in the service unit is deliberate: in testing, RequiresMountsFor= did not produce the dependency for paths containing a space, and every username with a space in it is such a path.
  • Step 9 now says plainly that encryption is not retroactive — everything stored afterwards is encrypted, while Nextcloud's default skeleton predates the setting and stays plaintext on the Storage Box. Stock demo content, not user data, and the answer if the Step 14 HBEGIN probe comes back plaintext on one of those files.
  • Step 10 now moves a user's existing files onto the Storage Box before the bind mount goes on top, instead of treating that as an afterthought. Every account starts with a skeleton (Documents/, Photos/, Templates/) that the mount would otherwise hide while leaving it in oc_filecache — the user sees the folders and cannot write into them. Also added a recovery procedure for installs that already mounted over their files.
  • Added Step 16, a capacity and availability guard that auto-enables maintenance mode when the Storage Box fills up or disconnects, with an optional second notification channel — needed because maintenance mode blocks Nextcloud's own notification:generate, so the alert announcing that writes stopped cannot travel through Nextcloud. A hard mount prevents truncated writes but does nothing about a full or vanished share, and the two protections are much weaker apart than together.

Iteration 2 - Update and reboot notifications

  • Added Step 13: unattended-upgrades for security patches, a Nextcloud notification when a reboot is pending (because full-disk encryption means auto-reboot would strand the machine at the LUKS prompt), and a periodic digest of general updates.

Iteration 1 - Encryption key separation

  • NEXTCLOUD_DATADIR moved from the Storage Box to the local LUKS disk, with only per-user files/, files_trashbin/, files_versions/ and uploads/ bind-mounted from the Storage Box. Previously the datadir pointed straight at the Storage Box, which put the encryption keys next to the encrypted files and defeated the point of encrypting them.
  • Added Step 11, the mounts verification service, so Docker cannot start against missing bind mounts and write plaintext into the wrong place.
  • See Migration from Previous Guide below if you are still on the old layout.

Migration from Previous Guide

If you followed an earlier version of this guide where NEXTCLOUD_DATADIR pointed directly to the Storage Box (/mnt/myshare), your encryption keys are currently stored alongside your encrypted files. This section describes how to migrate to the more secure architecture.

Understanding the risk

In the old setup:

  • Encryption keys: /mnt/myshare/_data/files_encryption/ (on Storage Box)
  • Encrypted files: /mnt/myshare/_data/<user>/files/ (on Storage Box)

Anyone with access to your Storage Box has both the ciphertext AND the keys.

Migration overview

A. Stop Nextcloud
B. Copy encryption keys and config to local disk
C. Copy per-user local data
D. Update NEXTCLOUD_DATADIR to use local Docker volume
E. Set up per-user bind mounts for file storage
F. Start Nextcloud

Step-by-step migration

A. Stop Nextcloud and take backups

Stop the stack with "Stop containers" in the AIO interface, and wait until it reports everything stopped. Do not use docker compose down for this: it removes the mastercontainer, but the AIO containers carry an unless-stopped restart policy and keep running — and the next two sections copy your encryption keys and appdata_*, which must not be read out from under a live Nextcloud.

Take a VPS snapshot and Storage Box snapshot before proceeding.

B. Create the local data directory and copy global files

mkdir -p /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data

Copy encryption keys and essential files to local disk:

# Copy encryption keys (CRITICAL)
cp -a /mnt/myshare/_data/files_encryption /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/

# Copy appdata
cp -a /mnt/myshare/_data/appdata_* /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/

# Copy config files
cp -a /mnt/myshare/_data/.htaccess /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/
cp -a /mnt/myshare/_data/.ncdata /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/
cp -a /mnt/myshare/_data/.noaiobackup /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/
cp -a /mnt/myshare/_data/index.html /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/

Some of these will not exist, and that is fine. .noaiobackup is only there if you did Step 12, index.html and .ncdata depend on your Nextcloud version, and a user's cache/ only exists once something created it. cp: cannot stat on those is expected — you are copying encryption keys here, so read the errors rather than assuming the worst, but do not treat a missing optional file as data loss.

C. Copy per-user local data

For each user, copy the folders that should remain local, then create empty directories for the bind mounts:

# Example for user 'admin'

# Copy folders that stay local
cp -a /mnt/myshare/_data/admin/cache /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/
cp -a /mnt/myshare/_data/admin/files_encryption /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/

# Create empty directories for bind mounts
mkdir -p /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin/{files,files_trashbin,files_versions,uploads}

# Set ownership
chown -R www-data:www-data /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/admin

Repeat for each user. For usernames with spaces:

cp -a "/mnt/myshare/_data/Hans Werner/cache" "/var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/Hans Werner/"
cp -a "/mnt/myshare/_data/Hans Werner/files_encryption" "/var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/Hans Werner/"
mkdir -p "/var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/Hans Werner"/{files,files_trashbin,files_versions,uploads}
chown -R www-data:www-data "/var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/Hans Werner"

D. Update compose.yml

Edit ~/containers/nextcloud/compose.yml and change:

NEXTCLOUD_DATADIR: /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/

E. Set up bind mounts and verification service

Follow Step 10 to add fstab entries for each user, binding their Storage Box folders into the local data directory.

Only the fstab part of Step 10 applies here. Step 10 is written for a fresh install, where a user's files start out on the local disk and have to be moved onto the Storage Box before the mount goes on top. You are coming from the opposite direction: the files are already on the Storage Box and section C above deliberately left the local directories empty as bind targets. Skip the move, and skip the files:scan — there is nothing to relocate and the file cache already describes the Storage Box content correctly.

Follow Step 11 to create nextcloud-mounts-check.service and update Docker dependencies.

F. Start Nextcloud

Establish the bind mounts first, so nothing comes up against an empty directory:

systemctl daemon-reload
mount -a

Then apply the compose.yml change from section D — that edit is the entire point of this migration, not an optional extra, and without it the bind mounts are in place while NEXTCLOUD_DATADIR still points at the Storage Box:

cd ~/containers/nextcloud && docker compose up -d

That recreates the mastercontainer with the new NEXTCLOUD_DATADIR. Then start the containers from its interface.

G. Verify everything works

  • Log into Nextcloud and check that files are accessible
  • Verify mounts are active: mount | grep nextcloud_aio_nextcloud_data
  • Check encryption keys are local: ls -la /var/lib/docker/volumes/nextcloud_aio_nextcloud_data/_data/files_encryption/

H. Clean up old data on Storage Box (optional)

Once you've confirmed everything works, you can remove the now-redundant files from the Storage Box:

# Remove global files that are now local
rm -rf /mnt/myshare/_data/files_encryption
rm -rf /mnt/myshare/_data/appdata_*
rm -f /mnt/myshare/_data/.htaccess
rm -f /mnt/myshare/_data/.ncdata

# For each user, remove the folders that are now local
rm -rf /mnt/myshare/_data/admin/cache
rm -rf /mnt/myshare/_data/admin/files_encryption
# Repeat for other users

Keep the user files/, files_trashbin/, files_versions/, and uploads/ folders — they contain your actual file data and are now bind-mounted.

After migration

Your encryption keys now live exclusively on the LUKS-encrypted VPS disk. Even if someone gains access to your Storage Box, they cannot decrypt your files.

Conclusion

Congratulations! You now have your own private Nextcloud with:

  • Proper encryption key separation — keys on LUKS-encrypted local disk, encrypted files on expandable Storage Box
  • 1TB+ of expandable storage — increase Storage Box size anytime without rebuilding
  • Full control — your data, your server, your rules

Have fun exploring your Nextcloud — set up calendars, contacts, notes, video calls, and all the other features that make it a genuine alternative to Big Tech cloud services.

The setup requires a bit more administration than a simple "point everything at the Storage Box" approach, but the security improvement is significant. Your encryption keys never leave your control.

Enjoy your private cloud!

License: MIT
Want to contribute?

Get Rewarded: Get up to €50 in credit! Be a part of the community and contribute. Do it for the money. Do it for the bragging rights. And do it to teach others!

Report Issue
Try Hetzner Cloud

Get €20/$20 free credit!

Valid until: 31 December 2026 Valid for: 3 months and only for new customers
Get started
Want to contribute?

Get Rewarded: Get up to €50 credit on your account for every tutorial you write and we publish!

Find out more