Every photo your phone takes and every file you save to Google Drive, iCloud, or Dropbox lives on someone else's computer — scanned, profiled, and subject to the laws of whatever country holds it. You pay monthly for the privilege.
Nextcloud is free, open-source software that turns a server into your own private Dropbox and Google-Photos replacement: file sync across your devices, automatic phone-photo backup, calendars, contacts, and more. Run it on a server in Switzerland and you pair that self-ownership with some of the strongest data-protection law on the planet.
This guide is the real thing — a start-to-finish install on Debian 13, written so someone with basic command-line comfort can follow it. Where we hit a wall during the build, we left the wall in, marked like this:
⚠️ Gotcha: This is a problem we actually ran into, why it happens, and the fix. These are the parts most tutorials quietly edit out.
One honest note up front: when you self-host, you become the administrator, and an admin can technically read what users upload. That’s how running your own server works. “Private” here means private from Google and the outside world — be upfront with the people you invite.
Official project documentation lives at docs.nextcloud.com if you want to go deeper on anything below.
• One server (a virtual machine) runs Nextcloud. It has a public internet address so phones can reach it from anywhere.
• Clients — apps on your computers and phones — sync files to and from it.
• A web address like cloud.example.com points at the server.
• HTTPS (the padlock) encrypts everything in transit.
The software stack:
| Piece | Plain-English Job |
|---|---|
| Debian 13 (Trixie) | The operating system. Stable, minimal Linux. |
| nginx | The web server. Answers browsers and apps. |
| PHP 8.4 | The language Nextcloud is written in. |
| MariaDB | The database — stores the index of files/users/settings, not the files. |
| Redis | Fast in-memory helper for file locking and caching. |
| Nextcloud | The application itself. |
Why this exact stack? Debian 13 ships PHP 8.4 and MariaDB 11.8 natively — no untrusted third-party repositories needed — and current Nextcloud runs cleanly on it. Fewer moving parts, smaller security surface.
Placeholders — swap these for your own:
| Placeholder | Meaning |
|---|---|
| cloud.example.com | Your web address |
| 203.0.113.2 | Your server’s public IP |
| 203.0.113.1 | Your network gateway |
| 198.51.100.10, 198.51.100.11 | Your management/VPN IPs (admin + SSH access) |
| support@example.com | Your email (certificate notices) |
| STRONG_DB_PASSWORD, STRONG_ADMIN_PASSWORD | Passwords you generate |
Generate strong passwords with openssl rand -base64 24.
Everything below runs as root over SSH.
Provision a virtual machine:
• 4 vCPU, 8 GB RAM (4 GB is the floor; 8 GB is comfortable once caching and photo-thumbnailing run).
• ~1 TB disk. The initial dump of everyone’s existing camera rolls is the big hit — a single heavy 4K-video shooter can be 150 GB alone — so size generously.
• Debian 13 (64-bit), minimal install, SSH server only, no desktop.
• A public IPv4 address.
A note on storage: our server’s disk sits on Ceph (clustered storage), so we didn’t bother carving a separate data partition — the storage layer handles resize and snapshots underneath the VM. If your disk is plain local storage and you want the ability to grow/snapshot the data independently, put the data directory on its own volume. Otherwise a single root filesystem is fine, and that’s what we use here.
If your provider doesn’t auto-configure networking, set a static IP. Edit /etc/network/interfaces:
nano /etc/network/interfaces
Make the primary interface (often ens18) look like this:
auto ens18
iface ens18 inet static
address 203.0.113.2/30
gateway 203.0.113.1
dns-nameservers 1.1.1.1 9.9.9.9
dns-search example.com
The key detail: auto ens18 (not allow-hotplug) so the interface comes up at every boot, not just on a hotplug event. Apply it:
systemctl restart networking
ip -4 addr show ens18
ip route
You should see your address and a default route via the gateway.
apt update && apt full-upgrade -y
apt install -y curl wget unzip gnupg ca-certificates nano
timedatectl set-timezone Europe/Zurich
hostnamectl set-hostname nextcloud
If the upgrade installs a new kernel, reboot once to activate it and to confirm your static IP survives a cold boot.
A firewall decides which ports are reachable. We want only:
• 80 (plain web) — briefly, for the certificate, and to redirect to HTTPS.
• 443 (HTTPS) — all real traffic.
• 22 (SSH) — only from your own management IPs, never the whole internet.
We use UFW:
apt install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow from 198.51.100.10 to any port 22 proto tcp
ufw allow from 198.51.100.11 to any port 22 proto tcp
ufw enable
⚠️ Gotcha: Make sure you’re SSHed in from one of those management IPs before enabling, or you’ll lock yourself out. If unsure, temporarily add
ufw allow 22/tcp(SSH open to all), finish the build, then remove it withufw delete allow 22/tcp. Keep console access (via your hypervisor) as the recovery path.
Confirm with ufw status verbose.
mkdir -p /var/nextcloud-data
chown -R www-data:www-data /var/nextcloud-data
chmod 750 /var/nextcloud-data
www-data is the user the web server runs as; the data must be owned by it.
One command pulls in everything, including every PHP module Nextcloud checks for and the media tooling for thumbnails:
apt install -y nginx mariadb-server redis-server \
php8.4-fpm php8.4-cli php8.4-gd php8.4-mysql php8.4-curl \
php8.4-mbstring php8.4-intl php8.4-bcmath php8.4-gmp \
php8.4-xml php8.4-zip php8.4-apcu php8.4-redis php8.4-bz2 \
php8.4-imagick imagemagick ffmpeg
• php8.4-imagick + imagemagick — thumbnails for more image types, including iPhone HEIC photos.
• ffmpeg — thumbnails and previews for videos.
Redis defaults to listening on a network port we don’t need. We switch it to a local socket (never touches the network) and let the web user reach it.
nano /etc/redis/redis.conf
Set three directives:
port 0
unixsocket /run/redis/redis-server.sock
unixsocketperm 770
Then add the web user to the redis group and restart:
usermod -aG redis www-data
systemctl restart redis-server
ls -l /run/redis/redis-server.sock
⚠️ Gotcha #1 — indentation matters. When editing redis.conf, make sure the unixsocket lines have no leading spaces. Redis ignores indented directives, so a stray space means the setting silently doesn’t apply.
⚠️ Gotcha #2 — the permission has to be 770, not 700. If the socket comes up as
srwx------(700), the web user can’t reach it even though it’s in the redis group. You wantsrwxrwx--- redis redis(770). If it’s wrong:sed -i -E 's|^\s*unixsocketperm.*|unixsocketperm 770|' /etc/redis/redis.conf systemctl restart redis-server⚠️ Gotcha #3 — restart PHP after the group change. PHP-FPM workers only pick up the new redis group membership when restarted. If you installed PHP before adding www-data to the group, run
systemctl restart php8.4-fpmnow, or you’ll see intermittent Redis server went away / Connection refused errors in the logs.
Verify the web user can actually reach Redis:
setpriv --reuid=www-data --regid=www-data --init-groups redis-cli -s /run/redis/redis-server.sock ping
PONG means you’re good.
Run the security wizard. When it asks to switch root to socket auth or set a root password, answer no (explained below); answer yes to removing anonymous users, disallowing remote root, and dropping the test database.
mariadb-secure-installation
Why we don’t set a database root password: on Debian, MariaDB’s root uses unix_socket authentication — it trusts the operating-system identity instead of a password. Only a process already running as system root can log in as DB root. There’s no password to steal, leak, or brute-force, and if an attacker were already OS-root they could read the data anyway. A password here adds friction, not security. The account that does get a strong password is the dedicated application user below, because applications authenticate with a credential, not an OS identity.
Create the database and application user — generate a password first and keep it:
openssl rand -base64 24 # this is STRONG_DB_PASSWORD — save it
mariadb
At the MariaDB [(none)]> prompt:
CREATE DATABASE nextcloud CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER 'ncadmin'@'localhost' IDENTIFIED BY 'STRONG_DB_PASSWORD';
GRANT ALL PRIVILEGES ON nextcloud.* TO 'ncadmin'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Why
utf8mb4? It stores anything, including emoji in filenames. Setting it at creation avoids a known migration warning later.
Add database tuning for Nextcloud’s workload:
cat > /etc/mysql/mariadb.conf.d/90-nextcloud.cnf <<'EOF'
[mysqld]
transaction_isolation = READ-COMMITTED
innodb_file_per_table = 1
innodb_buffer_pool_size = 1G
innodb_flush_log_at_trx_commit = 2
innodb_io_capacity = 4000
EOF
systemctl restart mariadb
Nextcloud is not installed from apt — the packaged version lags. Download the official release and verify its checksum before trusting it:
cd /tmp
wget https://download.nextcloud.com/server/releases/latest.zip
wget https://download.nextcloud.com/server/releases/latest.zip.sha256
sha256sum -c latest.zip.sha256
⚠️ Gotcha: you’ll see
latest.zip: OK(good) followed bylatest.metadata: FAILED open or read. That second line is harmless — the checksum file lists a metadata file we didn’t download. The line that matters islatest.zip: OK.
Unpack into place and set ownership:
unzip -q latest.zip
mv nextcloud /var/www/
chown -R www-data:www-data /var/www/nextcloud
grep OC_VersionString /var/www/nextcloud/version.php
PHP’s defaults cap uploads at 2 MB, which rejects every video. Raise the limits:
cat > /etc/php/8.4/fpm/conf.d/99-nextcloud.ini <<'EOF'
memory_limit = 512M
upload_max_filesize = 16G
post_max_size = 16G
max_execution_time = 3600
max_input_time = 3600
output_buffering = Off
date.timezone = Europe/Zurich
opcache.enable = 1
opcache.enable_cli = 1
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 10000
opcache.memory_consumption = 256
opcache.save_comments = 1
opcache.revalidate_freq = 60
apc.enable_cli = 1
apc.shm_size = 128M
EOF
ln -s /etc/php/8.4/fpm/conf.d/99-nextcloud.ini /etc/php/8.4/cli/conf.d/99-nextcloud.ini
systemctl restart php8.4-fpm
What these actually control at upload time:
• upload_max_filesize — the biggest single file PHP accepts (16 GB here).
• post_max_size — total request size; must be ≥ the upload limit, so we match them.
• memory_limit — RAM per PHP process. Not the upload ceiling — big files stream to disk in chunks, so a 16 GB upload doesn’t need 16 GB of RAM. This is for thumbnailing, zip-on-download, etc.
• max_execution_time / max_input_time — seconds a request may run (1 hour), so slow uploads over weak connections don’t time out.
• output_buffering = Off — required by Nextcloud; if on, large downloads break.
Important — PHP isn’t the only gatekeeper. An upload must pass every limit in the chain: nginx’s
client_max_body_size(set in the next step — if it’s smaller than PHP’s, nginx rejects the file first with a 413), then PHP, then Nextcloud’s own app setting, then the user’s storage quota, then actual free disk. Match nginx and PHP, and the practical ceiling becomes “whatever fits the user’s quota and the disk.”
Two files: a helper (PHP socket, cache map, and the admin-access rules), then the site.
11a — the helper (/etc/nginx/conf.d/nextcloud-maps.conf):
upstream php-handler {
server unix:/run/php/php8.4-fpm.sock;
}
map $arg_v $asset_immutable {
"" "";
default ", immutable";
}
# Is the request from one of your management IPs? 1 = yes
geo $mgmt_network {
default 0;
127.0.0.1 1;
198.51.100.10 1;
198.51.100.11 1;
}
# Is the request aimed at the admin pages? 1 = yes
map $request_uri $admin_area {
default 0;
"~*^/(?:index\.php/)?settings/admin" 1;
}
# Serve JavaScript modules with the correct MIME type
types {
text/javascript mjs;
}
⚠️ Gotcha — the
.mjsMIME type. Nextcloud serves some code as JavaScript modules (.mjs files). If nginx labels them with the wrong MIME type, browsers refuse to run them and parts of the UI break. Thetypes { text/javascript mjs; }block above fixes it. Note it’smjsonly — don’t addjsto that line, or nginx throws a “duplicate extension” warning becausejsis already mapped by default.
11b — the site (/etc/nginx/sites-available/nextcloud.conf). The admin-access rule here says: if the request is for the admin pages and it’s not from your management network, return 403. Your users never touch the admin pages, so they’re unaffected — only admin access is network-restricted.
server {
listen 80;
listen [::]:80;
server_name cloud.example.com;
location ^~ /.well-known/acme-challenge/ { root /var/www/nextcloud; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name cloud.example.com;
ssl_certificate /etc/letsencrypt/live/cloud.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cloud.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Admin panel restricted to your management IPs
set $block_admin "";
if ($admin_area) { set $block_admin "a"; }
if ($mgmt_network = 0) { set $block_admin "${block_admin}b"; }
if ($block_admin = "ab") { return 403; }
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Permitted-Cross-Domain-Policies "none" always;
add_header X-Robots-Tag "noindex, nofollow" always;
root /var/www/nextcloud;
index index.php index.html;
client_max_body_size 16G;
client_body_timeout 3600s;
fastcgi_buffers 64 4K;
gzip on;
gzip_vary on;
gzip_comp_level 4;
gzip_min_length 256;
gzip_types application/javascript application/json text/css text/plain image/svg+xml;
location = /robots.txt { allow all; log_not_found off; access_log off; }
location ^~ /.well-known {
location = /.well-known/carddav { return 301 /remote.php/dav/; }
location = /.well-known/caldav { return 301 /remote.php/dav/; }
location /.well-known/acme-challenge { try_files $uri $uri/ =404; }
return 301 /index.php$request_uri;
}
location ~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:$|/) { return 404; }
location ~ ^/(?:\.|autotest|occ|issue|indie|db_|console) { return 404; }
location ~ \.php(?:$|/) {
rewrite ^/(?!index|remote|public|cron|core\/ajax\/update|status|ocs\/v[12]|updater\/.+|ocs-provider\/.+) /index.php$request_uri;
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
set $path_info $fastcgi_path_info;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $path_info;
fastcgi_param HTTPS on;
fastcgi_param modHeadersAvailable true;
fastcgi_param front_controller_active true;
fastcgi_pass php-handler;
fastcgi_intercept_errors on;
fastcgi_request_buffering off;
fastcgi_max_temp_file_size 0;
fastcgi_read_timeout 3600;
fastcgi_send_timeout 3600;
}
location ~ \.(?:css|js|mjs|svg|gif|png|jpg|ico|wasm|map|ogg|flac)$ {
try_files $uri /index.php$request_uri;
add_header Cache-Control "public, max-age=15778463$asset_immutable";
access_log off;
}
location ~ \.woff2?$ {
try_files $uri /index.php$request_uri;
expires 7d;
access_log off;
}
location /remote { return 301 /remote.php$request_uri; }
location / { try_files $uri /index.php$request_uri; }
}
⚠️ Gotcha — the one that cost us the most time. Look closely at that last line:
location / { try_files $uri /index.php$request_uri; }Many copy-pasted configs write it as
try_files $uri $uri/ /index.php$request_uri;— with$uri/in the middle. That extra$uri/tells nginx: “if a real directory matches the URL, serve the directory.” Nextcloud has real folders at paths like/apps/files/, so a request to/apps/files/matches the directory on disk, nginx tries to list it, directory listing is off → 403 Forbidden on every app page. The maddening part: the login page works fine, so it looks like an authentication problem when it’s actually directory indexing. The fix is to remove$uri/— route everything that isn’t a real file straight to Nextcloud’s front controller. The symptom to recognize, in/var/log/nginx/error.log:
directory index of "/var/www/nextcloud/apps/files/" is forbidden
Enable the site and test:
rm -f /etc/nginx/sites-enabled/default
ln -s /etc/nginx/sites-available/nextcloud.conf /etc/nginx/sites-enabled/
nginx -t
The test will fail on the missing certificate — expected. We fix that next.
⚠️ Gotcha — the chicken-and-egg. Your config has a
listen 443 sslblock pointing at certificate files that don’t exist yet. nginx refuses to start an SSL listener with no certificate — but Certbot needs nginx running to validate the domain and issue that certificate. Each needs the other first. The way out: stand up a temporary port-80-only config, get the cert, then switch back.
Create a minimal bootstrap config and point nginx at it:
cat > /etc/nginx/sites-available/nextcloud-bootstrap.conf <<'EOF'
server {
listen 80;
listen [::]:80;
server_name cloud.example.com;
root /var/www/nextcloud;
location ^~ /.well-known/acme-challenge/ { root /var/www/nextcloud; }
}
EOF
rm -f /etc/nginx/sites-enabled/nextcloud.conf
ln -s /etc/nginx/sites-available/nextcloud-bootstrap.conf /etc/nginx/sites-enabled/
nginx -t && systemctl restart nginx
Before continuing, make sure your DNS
Arecord forcloud.example.compoints at the server’s public IP — Let’s Encrypt checks it. Confirm withgetent hosts cloud.example.com.
Request the certificate:
apt install -y certbot
certbot certonly --webroot -w /var/www/nextcloud \
-d cloud.example.com \
--email support@example.com --agree-tos --no-eff-email
On success, switch back to the full config and reload:
rm -f /etc/nginx/sites-enabled/nextcloud-bootstrap.conf
ln -s /etc/nginx/sites-available/nextcloud.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
Certbot installs an auto-renewal timer. Add a hook so nginx reloads when the cert renews:
echo -e '#!/bin/sh\nsystemctl reload nginx' > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
Confirm HTTPS answers:
curl -I https://cloud.example.com
An HTTP/2 200 with security headers means the stack is live.
Nextcloud’s admin tool, occ, must run as the www-data user (the owner of the config), never as root. On a minimal Debian box sudo often isn’t installed, and shell quoting tricks (sudo -u www-data php ...) can mangle arguments.
⚠️ Gotcha — the wrapper. A common wrapper uses
printf %qto quote arguments, but Debian’s default/bin/sh(dash) doesn’t support%qand throws invalid directive. The robust fix issetpriv, which drops privileges cleanly and passes arguments through untouched:
cat > /usr/local/bin/occ <<'EOF'
#!/bin/bash
exec setpriv --reuid=www-data --regid=www-data --init-groups php /var/www/nextcloud/occ "$@"
EOF
chmod +x /usr/local/bin/occ
Now occ <anything> just works. Test:
occ status
Run the installer from the command line (no exposed setup page). Generate an admin password and keep it, then install with your DB password:
openssl rand -base64 24 # this is STRONG_ADMIN_PASSWORD — save it
setpriv --reuid=www-data --regid=www-data --init-groups \
php /var/www/nextcloud/occ maintenance:install \
--database "mysql" \
--database-name "nextcloud" \
--database-user "ncadmin" \
--database-pass "STRONG_DB_PASSWORD" \
--admin-user "admin" \
--admin-pass "STRONG_ADMIN_PASSWORD" \
--data-dir "/var/nextcloud-data"
⚠️ Gotcha — “Console has to be executed with the user that owns config.php.” If you run
occorcron.phpas root (user id 0) when the files are owned by www-data (user id 33), Nextcloud refuses with exactly that message. It’s correct behaviour, not a bug — always run these as www-data (which the wrapper above handles).
Success prints Nextcloud was successfully installed.
Security note: the passwords are now in your shell history. Once everything works, clear it with
history -c && history -w.
Set the trusted domain and URLs, and wire in Redis + APCu caching:
occ config:system:set trusted_domains 0 --value="cloud.example.com"
occ config:system:set overwrite.cli.url --value="https://cloud.example.com"
occ config:system:set default_phone_region --value="CH"
occ config:system:set maintenance_window_start --type=integer --value=1
occ config:system:set memcache.local --value="\OC\Memcache\APCu"
occ config:system:set memcache.distributed --value="\OC\Memcache\Redis"
occ config:system:set memcache.locking --value="\OC\Memcache\Redis"
occ config:system:set redis host --value="/run/redis/redis-server.sock"
occ config:system:set redis port --type=integer --value=0
Fresh installs leave a few optimizations undone (Nextcloud nags about them in the admin panel):
occ db:add-missing-primary-keys
occ db:add-missing-columns
occ db:add-missing-indices
Nextcloud has scheduled housekeeping. The reliable method is a system cron job owned by www-data:
(crontab -u www-data -l 2>/dev/null; echo "*/5 * * * * php -f /var/www/nextcloud/cron.php") | crontab -u www-data -
occ background:cron
⚠️ Gotcha — the Fontconfig cache error. When cron runs, you may see
Fontconfig error: No writable cache directories. It’s a harmless gripe from the imaging library — the www-data user has no home directory to cache fonts in. Silence it by giving it one:mkdir -p /var/www/.cache/fontconfig chown -R www-data:www-data /var/www/.cache
Nextcloud has a built-in diagnostic:
occ setupchecks
Most lines should be green (✓). The real items to clear:
# Apply any pending mimetype migrations
occ maintenance:repair --include-expensive
# Trigger cron once so "last run" isn't flagged
setpriv --reuid=www-data --regid=www-data --init-groups php -f /var/www/nextcloud/cron.php
Two info-level items are fine to defer: email server (needed for password-reset emails — point it at your mail provider later in the admin UI) and 2FA enforcement, which we turn on next.
Note on the “remote address could not be determined” lines: these appear because the health check runs from the command line, which has no client IP. They resolve correctly for real web requests. Ignore them.
Two-factor authentication
The single strongest control for an internet-facing login. Enable and enforce TOTP (authenticator-app codes):
occ app:enable twofactor_totp
occ twofactorauth:enforce --on
What this does, in practice: every user is required to set up an authenticator app (Aegis, Google Authenticator, etc.) on their next web login — password plus a 6-digit rotating code.
The key thing most people get wrong about 2FA: it only applies to interactive logins (the web browser). The desktop and mobile sync clients authenticate with per-device app passwords, which bypass the 2FA prompt entirely. So day-to-day photo auto-upload and file sync are never interrupted by 2FA — it only appears at browser login. Set up your own authenticator the first time you log in, and save the backup codes it shows you — that’s your recovery if you lose the phone.
Fail2ban
Bans IP addresses that hammer the login:
apt install -y fail2ban
occ config:system:set log_type --value="file"
occ config:system:set logfile --value="/var/nextcloud-data/nextcloud.log"
occ config:system:set loglevel --type=integer --value=2
occ config:system:set logtimezone --value="Europe/Zurich"
Create the filter:
cat > /etc/fail2ban/filter.d/nextcloud.conf <<'EOF'
[Definition]
_groupsre = (?:(?:,?\s*"\w+":(?:"[^"]*"|\w+))*)
failregex = ^\{%(_groupsre)s,?\s*"remoteAddr":"<ADDR>"%(_groupsre)s,?\s*"message":"Login failed:
^\{%(_groupsre)s,?\s*"remoteAddr":"<ADDR>"%(_groupsre)s,?\s*"message":"Trusted domain error
datepattern = ,?\s*"time"\s*:\s*"%%Y-%%m-%%dT%%H:%%M:%%S
EOF
Create the jail (5 strikes in 10 minutes → 1-hour ban):
cat > /etc/fail2ban/jail.d/nextcloud.conf <<'EOF'
[nextcloud]
enabled = true
port = 80,443
protocol = tcp
filter = nextcloud
maxretry = 5
bantime = 3600
findtime = 600
logpath = /var/nextcloud-data/nextcloud.log
EOF
systemctl restart fail2ban
⚠️ Gotcha #1 — the socket race. Right after restarting fail2ban,
fail2ban-client status nextcloudmay reportFailed to access socket path... Is fail2ban running?. That’s just the status query hitting the daemon a split second before it finished starting. Wait a moment and re-run —systemctl status fail2banwill confirm it’s actually running.⚠️ Gotcha #2 — “0 matched” is normal on a fresh install. Testing the filter with
fail2ban-regexagainst a brand-new log shows 0 matched simply because there are no failed logins yet — not because the filter is broken. The filter is working; it just has nothing to catch. Verify it parses without errors, and confirm it catches a real event later by deliberately fumbling a login once.
Confirm the jail is up:
systemctl status fail2ban --no-pager | head -5
fail2ban-client status nextcloud
Out of the box, photos appear in a plain file browser. Two apps fix that — Memories (a photo timeline with map and search) and Preview Generator (pre-makes thumbnails so browsing is instant):
occ app:install memories
occ app:install previewgenerator
Configure which file types get thumbnails (including HEIC and video), cap their size so they don’t bloat the disk, and set quality:
occ config:system:set enabledPreviewProviders 0 --value="OC\Preview\PNG"
occ config:system:set enabledPreviewProviders 1 --value="OC\Preview\JPEG"
occ config:system:set enabledPreviewProviders 2 --value="OC\Preview\GIF"
occ config:system:set enabledPreviewProviders 3 --value="OC\Preview\HEIC"
occ config:system:set enabledPreviewProviders 4 --value="OC\Preview\MP4"
occ config:system:set enabledPreviewProviders 5 --value="OC\Preview\MOV"
occ config:system:set enabledPreviewProviders 6 --value="OC\Preview\TXT"
occ config:system:set preview_max_x --type=integer --value=2048
occ config:system:set preview_max_y --type=integer --value=2048
occ config:app:set preview jpeg_quality --value="60"
Why
ffmpegandimagickmattered: video thumbnails (MP4/MOV) need ffmpeg, and HEIC previews from iPhones need imagick — both installed back in Step 6. Skip them and your photo library shows generic file icons instead of thumbnails.
Add the thumbnail pre-generation job to cron:
(crontab -u www-data -l 2>/dev/null; echo "*/10 * * * * php -f /var/www/nextcloud/occ preview:pre-generate") | crontab -u www-data -
Open https://cloud.example.com and log in as admin. You’ll immediately be walked through 2FA setup (have your authenticator app ready, and save the backup codes). Then you land on the dashboard.
Sanity-check from the command line:
occ status
occ setupchecks
A green board means you have a live, secured private cloud:
• ✅ HTTPS with auto-renewing certificate and full security headers
• ✅ Redis + APCu caching, system cron, tuned database
• ✅ Two-factor authentication enforced, fail2ban watching the login, admin panel locked to your network
• ✅ Photo timeline with HEIC and video thumbnails
• ✅ 16 GB upload ceiling, limited in practice only by quota and disk
From here, the day-to-day — installing the desktop sync client, turning on phone auto-upload, creating accounts for family and friends with storage quotas — is all done through the apps and the admin UI, and the official docs at docs.nextcloud.com cover each.
You run a private cloud that keeps your files and your family’s photos on your own server, on Swiss infrastructure, under Swiss data-protection law — not in an advertising company’s data centre. It’s encrypted in transit, protected by two-factor authentication, firewalled, brute-force-banned, and its admin panel is sealed off from the public internet.
The one honest caveat worth repeating: self-hosting moves trust from a faceless corporation to you, the administrator. For the people you invite, that’s usually a far better deal — but it’s a responsibility. Back up, patch, and be straight with your users about who holds the keys.
Standing this up is a satisfying project. Keeping it patched, backed up, monitored, and fast — forever — is a different commitment. If you want the privacy of Swiss-hosted, self-owned infrastructure without being the one on call at 3 a.m., that’s what SwissLayer builds: managed, private infrastructure in Switzerland, run by people who do this for a living.
Questions about any step, or about hosting it with us? Get in touch.