Migrating Nextcloud from Ubuntu 24.04 to a fresh Debian 13 server
Tested with: Nextcloud 34.0.4, Debian 13, PostgreSQL 18.6, PHP 8.4
In this article we will move a running Nextcloud instance from an Ubuntu 24.04 server to a freshly built Debian 13 server - new operating system, new PostgreSQL major version, new PHP - with only a few minutes of downtime and a rollback that is always one click away.
Our Nextcloud has been running on Ubuntu for a while now, and everything else in the homelab has slowly moved to Debian. There is no way to turn an Ubuntu server into a Debian one in place - it would mean a reinstall anyway - so we built a new server next to the old one, rehearsed the whole migration on it without touching the old one, and only then switched over. The new server takes over the old server's IP address at the end, so nothing in front of it - reverse proxy, DNS, clients - needs to change.
This article covers the full process: looking at what the old server actually runs, building the new one, rehearsing with a copy of the database, the cutover itself, and the problems we ran into on the way.
Our setup #
The setup is a simple one, and it is probably very close to what a lot of you are running:
- Traefik on another host takes care of TLS for
cloud.lab.networktechguy.comand forwards plain HTTP to the Nextcloud server. - Nextcloud server - a VM running nginx, PHP-FPM, PostgreSQL, Redis and ClamAV. Nothing else.
- Data directory on a NAS, mounted over NFS. This is the part that makes the migration fast: 90 GB of files stay where they are, only the server in front of them changes.
Users sign in through our identity provider (Authentik, over SAML) and documents are edited with an office server - both talk to Nextcloud through its public name, so they don't care which machine is behind it.
Addresses used in this article:
| What | Address |
|---|---|
| Traefik host | 10.20.0.81 |
| Old Nextcloud server (nc-01, Ubuntu 24.04) | 10.20.0.91 |
| New Nextcloud server (deb-nc-01, Debian 13) while it is built | 10.20.0.202 (DHCP) |
| New Nextcloud server after the cutover | 10.20.0.91 |
NAS (NFS export /Nextcloud) | 10.20.0.51 |
| Gateway and DNS | 10.20.0.1 |
If your data directory is on the Nextcloud server's own disk instead of a NAS, the process is the same - you will just need an extra rsync of the data directory, which we cover in the cutover step.
Prerequisites #
- SSH access with
sudoto the old server - A new VM (or physical box) with a minimal Debian 13 install and SSH
- The new VM on the same network as the old one (it will take over its address)
- Your Nextcloud admin password (just in case), and a recent backup of the old server
- A maintenance window of about 15-30 minutes for the cutover
Important: the new server must run exactly the same Nextcloud version as the old one. We are not upgrading Nextcloud here, only the system under it. Upgrade Nextcloud before or after the migration, never during.
Step 1: Find out what the old server actually runs #
Before building anything, let's take a good look at the old server. A Nextcloud that has been running for a few years tends to collect apps, tweaks and leftovers, and we want to bring the useful ones over - and only those.
Version, status and the system configuration (occ hides passwords and secrets in this output, so it is safe to look at):
cd /var/www/nextcloud
sudo -u www-data php occ status
sudo -u www-data php occ config:list system - installed: true
- version: 34.0.4.1
- versionstring: 34.0.4
- maintenance: false
- needsDbUpgrade: falseApps that didn't come with Nextcloud itself - each of them may need something on the new server:
sudo -u www-data php occ app:list --shipped=falseThe rest of the stack - running services, PHP version and modules, database and data sizes, cron jobs and mounts:
systemctl list-units --type=service --state=running | grep -Ei 'nginx|apache|php|postgres|mysql|maria|redis|clam'
php -v; php-fpm8.3 -m
sudo -u postgres psql -c "select datname, pg_size_pretty(pg_database_size(datname)) from pg_database"
sudo du -sh /var/www/nextcloud
sudo grep datadirectory /var/www/nextcloud/config/config.php
sudo crontab -l -u www-data
grep -v '^#' /etc/fstabAnd finally, who actually talks to the server. In our case only the Traefik host showed up in the nginx access log, which means we can lock port 80 down to that one address on the new server:
sudo tail -2000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rnThis is what we ended up with:
| Part | Old server |
|---|---|
| OS | Ubuntu 24.04, 4 vCPU, 8 GB RAM, 100 GB disk (15 GB used) |
| Nextcloud | 34.0.4 in /var/www/nextcloud (1.7 GB) |
| Web | nginx 1.24, plain HTTP on port 80 behind Traefik |
| PHP | 8.3 FPM |
| Database | PostgreSQL 16, 1.2 GB |
| Cache and locking | Redis 7 on a unix socket, APCu |
| Antivirus | ClamAV daemon (files_antivirus app) |
| Data | /share/ncdata on the NAS over NFS 4.1, 90 GB |
| Background jobs | System cron every 5 minutes |
| Extra apps | Calendar, Contacts, Notes, Group folders, Memories, Preview Generator, SAML, office integration, antivirus and a few smaller ones |
While looking around we also found a few things worth fixing on the way, so we will fix them on the new server instead of copying them:
- PHP sessions in files - the only errors in the Nextcloud log were "Slow session_start detected". Sessions will go to Redis (Valkey) instead.
pm.max_children = 100with a 1 GB PHP memory limit on an 8 GB server - on paper PHP could ask for far more memory than the server has.- Upload limits that don't agree - nginx allowed 512 MB per request, PHP allowed 4 GB.
opcache.validate_timestamps = 0- with this, app updates from the web interface don't take effect until PHP-FPM is restarted.- Preview Generator was enabled, but nothing was running
preview:pre-generate. - No database dump anywhere - only the hypervisor's nightly backup of the whole VM.
Step 2: Size the new server #
The old VM had 4 vCPUs and 8 GB of RAM. Do we actually need that? Let's measure instead of guessing:
ps -eo rss,comm --no-headers | awk '{m[$2]+=$1} END {for (k in m) if (m[k] > 50000) printf "%6.0f MB %s\n", m[k]/1024, k}' | sort -rn 2088 MB php-fpm8.3
969 MB clamd
197 MB postgresPHP-FPM looks big, but that is only because the old pool kept 16 idle workers around. ClamAV needs about 1 GB no matter what - that's its signature database in memory. The CPU was 96% idle on average.
For a Nextcloud with a handful of users, 4 GB of RAM is plenty once PHP-FPM is sized sensibly. We kept 4 vCPUs though - they cost almost nothing while idle, and they help when Memories transcodes a video or when previews are generated after a big upload. 40 GB of disk is more than enough, since the data lives on the NAS.
Step 3: Base system and packages #
Start with a minimal Debian 13 install (SSH server only), update it and install everything Nextcloud needs. Debian 13 ships PHP 8.4, which Nextcloud 34 supports:
sudo apt update && sudo apt -y full-upgrade
sudo apt install -y --no-install-recommends \
nginx \
php8.4-fpm php8.4-cli php8.4-apcu php8.4-bcmath php8.4-bz2 php8.4-curl php8.4-gd php8.4-gmp \
php8.4-igbinary php8.4-imagick php8.4-intl php8.4-ldap php8.4-mbstring php8.4-pgsql php8.4-redis \
php8.4-xml php8.4-zip php8.4-xsl php8.4-smbclient \
valkey-server clamav-daemon clamav-freshclam \
ffmpeg imagemagick libmagickcore-7.q16-10-extra \
libheif-plugin-libde265 libheif-plugin-aomdec libheif-plugin-aomenc \
nfs-common sysstat cron rsync unzip bzip2 curl ca-certificates gnupg perl python3A few notes on this list:
php-imapis gone - PHP 8.4 dropped the IMAP extension. Only the Mail app and IMAP sign-in use it; if you need either, check the app's documentation for alternatives before you migrate.- Valkey instead of Redis - it is the open source fork of Redis, it is in Debian 13, and Nextcloud talks to it with the same PHP extension and the same configuration.
- ffmpeg, perl and the libheif plugins are for the Memories app (video and HEIC photos from phones). Skip them if you don't use it.
Step 4: PostgreSQL 18 #
Debian 13 comes with PostgreSQL 17, but Nextcloud 34 supports PostgreSQL 18 and even recommends it. The PostgreSQL project runs its own Debian repository, and Debian's postgresql-common package has a script that adds it together with its signing key:
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt install -y postgresql-18
sudo -u postgres psql -c "select version()"PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bitGive it a bit more memory than the defaults - these values fit a 4 GB server:
# Nextcloud on a 4 GB VM
shared_buffers = 512MB
effective_cache_size = 2GB
work_mem = 16MB
maintenance_work_mem = 128MB
random_page_cost = 1.1sudo systemctl restart postgresql@18-mainStep 5: Valkey for caching, file locking and sessions #
We don't need Valkey on the network at all - a unix socket is faster and nothing outside the server can reach it. Nextcloud uses it as a cache, so we also tell it not to write anything to disk. Change port 6379 to port 0 in /etc/valkey/valkey.conf and add this at the end:
unixsocket /run/valkey/valkey-server.sock
unixsocketperm 770
save ""
maxmemory 256mb
maxmemory-policy allkeys-lruThe web server user needs access to the socket, and Valkey wants memory overcommit enabled:
sudo usermod -aG valkey www-data
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/99-valkey.conf
sudo sysctl --system
sudo systemctl restart valkey-serverStep 6: PHP 8.4 #
Instead of editing php.ini, we keep all Nextcloud settings in one file and link it into both the FPM and the CLI configuration - that way occ and the web server always run with the same settings:
memory_limit = 1024M
upload_max_filesize = 4096M
post_max_size = 4096M
max_execution_time = 3600
max_input_time = 3600
output_buffering = 0
date.timezone = America/Toronto
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 64
opcache.max_accelerated_files = 50000
opcache.save_comments = 1
opcache.validate_timestamps = 1
opcache.revalidate_freq = 60
apc.enable_cli = 1
apc.shm_size = 64M
session.save_handler = redis
session.save_path = "unix:///run/valkey/valkey-server.sock?persistent=1&database=1"
redis.session.locking_enabled = 1
redis.session.lock_retries = -1
redis.session.lock_wait_time = 10000sudo ln -s /etc/php/8.4/mods-available/nextcloud.ini /etc/php/8.4/fpm/conf.d/90-nextcloud.ini
sudo ln -s /etc/php/8.4/mods-available/nextcloud.ini /etc/php/8.4/cli/conf.d/90-nextcloud.iniopcache.validate_timestamps = 1 with revalidate_freq = 60 is what Nextcloud recommends: PHP notices changed files within a minute, so app updates from the web interface simply work.
And the FPM pool, sized for 4 GB. 24 workers is far more than a small instance will ever use at once, and only a few of them are kept running while idle:
[www]
user = www-data
group = www-data
listen = /run/php/php8.4-fpm.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 24
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
env[HOSTNAME] = $HOSTNAME
env[PATH] = /usr/local/bin:/usr/bin:/bin
env[TMP] = /tmp
env[TMPDIR] = /tmp
env[TEMP] = /tmp
php_admin_value[memory_limit] = 1024Msudo php-fpm8.4 -t
sudo systemctl restart php8.4-fpmStep 7: nginx #
Since Traefik takes care of TLS, nginx only listens on port 80. This is the configuration from the Nextcloud documentation with a few changes compared to our old one: the PHP 8.4 socket, the upload limit now matching PHP (4 GB), and .mjs files served as JavaScript - newer Nextcloud versions ship JavaScript modules and complain if they come back with the wrong type.
upstream php-handler {
server unix:/run/php/php8.4-fpm.sock;
}
map $arg_v $asset_immutable {
"" "";
default "immutable";
}
server {
listen 80;
listen [::]:80;
server_name cloud.lab.networktechguy.com;
root /var/www/nextcloud;
server_tokens off;
client_max_body_size 4096M;
client_body_timeout 300s;
client_body_buffer_size 512k;
fastcgi_buffers 64 4K;
gzip on;
gzip_vary on;
gzip_comp_level 4;
gzip_min_length 256;
gzip_proxied expired no-cache no-store private no_last_modified no_etag auth;
gzip_types application/atom+xml text/javascript application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/wasm application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
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;
fastcgi_hide_header X-Powered-By;
include mime.types;
types {
text/javascript mjs;
}
index index.php index.html /index.php$request_uri;
location = / {
if ( $http_user_agent ~ ^DavClnt ) {
return 302 /remote.php/webdav/$is_args$args;
}
}
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; }
location /.well-known/pki-validation { 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\/.+|.+\/richdocumentscode(_arm64)?\/proxy) /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 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 3600s;
}
location ~ \.(?:css|js|mjs|svg|gif|ico|jpg|png|webp|wasm|tflite|map|ogg|flac)$ {
try_files $uri /index.php$request_uri;
add_header Cache-Control "public, max-age=15778463, $asset_immutable";
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;
access_log off;
location ~ \.wasm$ {
default_type application/wasm;
}
}
location ~ \.(otf|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 $uri/ /index.php$request_uri;
}
}sudo ln -s /etc/nginx/sites-available/nextcloud /etc/nginx/sites-enabled/nextcloud
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginxNote about HSTS: there is no Strict-Transport-Security header here on purpose - TLS ends at Traefik, so that's where HSTS belongs. If your Traefik doesn't set it yet, Nextcloud's setup checks will remind you.
Step 8: ClamAV #
Only the limits from the old server need to come over - the rest of Debian's defaults are fine. Set these in /etc/clamav/clamd.conf:
MaxFileSize 25M
StreamMaxLength 25M
MaxScanSize 100MThe daemon won't start until freshclam has downloaded the virus database, which can take a few minutes on a new server. Check with:
ls /var/lib/clamav/
sudo systemctl restart clamav-daemon && systemctl is-active clamav-daemonStep 9: Firewall #
The old server had no firewall at all. Since we know only Traefik talks to it, the new one only accepts port 80 from the Traefik host and SSH from the internal network:
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 10.0.0.0/8 to any port 22 proto tcp comment "SSH from the internal network"
sudo ufw allow from 10.20.0.81 to any port 80 proto tcp comment "Traefik"
sudo ufw enable
sudo ufw status numberedTest both sides: from the Traefik host a request should work, from anywhere else it should time out.
# on the Traefik host
curl -s -H "Host: cloud.lab.networktechguy.com" http://10.20.0.202/status.phpStep 10: Rehearse the migration #
This is the step that makes the cutover boring - which is exactly what we want. We copy the database and the Nextcloud folder to the new server, and run Nextcloud there against the real data, while the old server keeps running as if nothing happened.
The tricky part is the data. Two Nextcloud servers must never write to the same data directory at the same time, so the new server can only mount the share read-only. But Nextcloud refuses to start on a read-only data directory:
Your data directory is not writable.
Exception: Environment not properly prepared.The solution is an overlay mount: the NAS share read-only at the bottom, a local folder on top that takes all the writes. Nextcloud sees a normal, writable data directory - and nothing it writes ever reaches the NAS:
sudo mkdir -p /mnt/nas-ro /share /var/tmp/nc-rehearsal/upper /var/tmp/nc-rehearsal/work
echo "10.20.0.51:/Nextcloud /mnt/nas-ro nfs4 ro,vers=4.1,hard,timeo=600,_netdev 0 0" | sudo tee -a /etc/fstab
sudo systemctl daemon-reload && sudo mount /mnt/nas-ro
sudo mount -t overlay overlay \
-o lowerdir=/mnt/nas-ro,upperdir=/var/tmp/nc-rehearsal/upper,workdir=/var/tmp/nc-rehearsal/work /shareCheck that the NAS lets the new server in, and that the files belong to the same user - www-data is uid 33 on both Ubuntu and Debian, so ownership carries over unchanged:
sudo ls -ln /mnt/nas-ro/ncdata | head -3
sudo -u www-data test -r /share/ncdata && echo "www-data can read the data"The database #
Create the same role the old server uses - you will find its name and password in the old config.php (dbuser and dbpassword):
sudo -u postgres psql -c "CREATE ROLE ncuser LOGIN PASSWORD '<dbpassword from config.php>'"
sudo -u postgres psql -c "CREATE DATABASE ncdb OWNER ncuser TEMPLATE template0 ENCODING UTF8 LC_COLLATE 'C.UTF-8' LC_CTYPE 'C.UTF-8'"A word on collation: our old database used en_US.UTF-8. We created the new one with C.UTF-8 on purpose - Nextcloud works with either, and C.UTF-8 doesn't depend on the C library's sorting rules. Those rules change between distributions (which is exactly what we are doing here), and when they change, PostgreSQL text indexes can quietly go wrong.
Now pipe a dump from the old server straight into the new one - no temporary files, and PostgreSQL 18 restores a dump from 16 without any extra steps. Run this from the new server:
ssh [email protected] 'sudo -u postgres pg_dump -Fc ncdb' | sudo -u postgres pg_restore --no-owner --role=ncuser -d ncdbOur 1.2 GB database came over in 41 seconds and ended up at 839 MB - a dump and restore also gets rid of the bloat a database collects over the years.
The Nextcloud folder #
Copy the whole folder, including config/config.php and all installed apps:
ssh [email protected] 'sudo tar -C /var/www -cf - nextcloud' | sudo tar -C /var/www -xpf -
sudo chown -R www-data:www-data /var/www/nextcloudBefore occ can run, config.php needs a few changes - the Redis socket path is different, and we want the log on the server instead of the NAS. Edit these entries directly in /var/www/nextcloud/config/config.php:
'redis' =>
array (
'host' => '/run/valkey/valkey-server.sock',
'port' => 0,
'timeout' => 0.0,
),
'maintenance' => true,
'logfile' => '/var/log/nextcloud/nextcloud.log',sudo install -d -o www-data -g www-data -m 750 /var/log/nextcloudWhy not occ config:system:set? Because occ itself tries to connect to the old Redis socket before it does anything, and fails with RedisException: No such file or directory. Editing the file by hand gets you past that.
Check it #
cd /var/www/nextcloud
sudo -u www-data php occ status
sudo -u www-data php occ app:list | grep -iE 'error|incompat'
sudo -u www-data php occ db:add-missing-indices
sudo -u www-data php occ db:add-missing-columns
sudo -u www-data php occ db:add-missing-primary-keys - installed: true
- version: 34.0.4.1
- versionstring: 34.0.4
- maintenance: true
- needsDbUpgrade: falseNow take the rehearsal copy out of maintenance mode for a moment and check it from the server itself (there is no cron yet, so nothing runs in the background):
sudo -u www-data php occ maintenance:mode --off
curl -s -H "Host: cloud.lab.networktechguy.com" http://127.0.0.1/status.php
sudo -u www-data php occ setupchecks
sudo -u www-data php occ maintenance:mode --onIf you use Memories, also check that its helper programs run on Debian. They are not executable on disk - Memories makes them executable itself when it needs them - so test them from a copy:
d=$(mktemp -d)
sudo cp /var/www/nextcloud/apps/memories/bin-ext/exiftool-amd64-glibc $d/ && sudo chmod +x $d/*
sudo $d/exiftool-amd64-glibc -ver
sudo rm -rf $dEverything worked on our side, which means the cutover is now just a repeat of what we already did - only with fresh data.
Step 11: Scheduled jobs and a nightly database dump #
Prepare the background jobs now, but keep them commented out until the cutover - the rehearsal copy shouldn't run anything on its own:
# Nextcloud background jobs - OFF until the cutover: remove the "#" from both lines then
#*/5 * * * * www-data php -f /var/www/nextcloud/cron.php
#*/15 * * * * www-data php /var/www/nextcloud/occ preview:pre-generate >/dev/null 2>&1The old server had no database dump at all, only the hypervisor's nightly backup of the VM. A dump right before that backup runs means every VM backup also holds a clean copy of the database that can be restored on its own:
#!/bin/sh
set -eu
dir=/var/backups/nextcloud
install -d -m 700 -o postgres -g postgres "$dir"
f="$dir/$(date -u +%Y%m%d-%H%M)-UTC-ncdb.dump"
runuser -u postgres -- pg_dump -Fc -f "$f.part" ncdb
mv "$f.part" "$f"
ls -1t "$dir"/*-ncdb.dump | tail -n +8 | xargs -r rm -f# runs at 01:05, before our hypervisor backup at 01:35; keeps the newest 7
5 1 * * * root /usr/local/sbin/nc-db-dumpAnd since the log now lives on the server, rotate it:
/var/log/nextcloud/nextcloud.log {
weekly
rotate 8
compress
delaycompress
missingok
notifempty
copytruncate
su www-data www-data
}Step 12: The cutover #
Time to do it for real. Our cutover took 9 minutes from maintenance mode on the old server to users being back on the new one. Here are the steps in order.
On the old server: stop everything #
cd /var/www/nextcloud
sudo -u www-data php occ maintenance:mode --on
sudo systemctl stop cron
pgrep -u www-data -f cron.php # wait until nothing is listedMove the database one last time #
On the new server, drop the rehearsal database and restore a fresh dump:
sudo -u postgres psql -c "DROP DATABASE ncdb"
sudo -u postgres psql -c "CREATE DATABASE ncdb OWNER ncuser TEMPLATE template0 ENCODING UTF8 LC_COLLATE 'C.UTF-8' LC_CTYPE 'C.UTF-8'"
ssh [email protected] 'sudo -u postgres pg_dump -Fc ncdb' | sudo -u postgres pg_restore --no-owner --role=ncuser -d ncdbA quick sanity check - the number of files Nextcloud knows about should be the same on both servers:
sudo -u postgres psql -d ncdb -Atc "select count(*) from oc_filecache" # run on bothRelease the data on the old server #
sudo systemctl stop nginx php8.3-fpm
sync && sudo umount /shareIf your data directory is on the old server's disk (no NAS), this is where you copy it over - rsync -aHAX --info=progress2 from the old server to the same path on the new one. Run the same rsync once during the rehearsal too, so the final run only has to copy what changed.
Switch the new server to the real data #
sudo umount /share && sudo umount /mnt/nas-ro
sudo rm -rf /var/tmp/nc-rehearsal
# fstab: the NAS straight at /share, read-write
sudo sed -i 's#^10.20.0.51:/Nextcloud /mnt/nas-ro nfs4 ro,#10.20.0.51:/Nextcloud /share nfs4 rw,#' /etc/fstab
sudo systemctl daemon-reload && sudo mount /shareCopy the Nextcloud folder once more (in case an app was updated since the rehearsal) and make the same config.php changes as before. Then bring it up:
cd /var/www/nextcloud
sudo -u www-data php occ maintenance:mode --off
sudo -u www-data php occ db:add-missing-indices
sudo -u www-data php -f cron.php && echo "cron ok"
sudo -u www-data php occ setupchecksAnd remove the # from both lines in /etc/cron.d/nextcloud.
Take over the IP address #
Shut the old server down and make sure nothing answers for its address anymore:
# on the old server
sudo systemctl poweroff
# on the new server: no reply means the address is free
sudo apt install -y arping
sudo arping -D -c 3 -I ens18 10.20.0.91... 100% packet loss (0 extra)Don't let the exit code fool you - this arping returns 1 when no one answers, which is the result we want here.
Changing the address over SSH cuts your own session, so let the change run as a small background job. Write the new configuration first:
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
allow-hotplug ens18
iface ens18 inet static
address 10.20.0.91/24
gateway 10.20.0.1#!/bin/sh
exec >/root/switch-ip.log 2>&1
ifdown ens18
install -m 644 /root/interfaces.new /etc/network/interfaces
ifup ens18
arping -U -c 3 -I ens18 -S 10.20.0.91 10.20.0.91 || truesudo systemd-run --unit=switch-ip sh /root/switch-ip.shThe last line of that script sends a few unsolicited ARP replies, so the gateway and the Traefik host learn the new server's MAC address right away instead of waiting for their ARP caches to expire. A few seconds later the server answers on the old address. Since ifupdown doesn't run DHCP anymore, also check that /etc/resolv.conf still has your DNS servers.
Connect to it again on 10.20.0.91 - and don't be surprised by this:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@Your SSH client still remembers the old server's key for that address. Compare the fingerprint it shows with the one you accepted when you first connected to the new server on its temporary address, and if they match, remove the old entry:
ssh-keygen -R 10.20.0.91Verification Checklist #
- Public address:
curl -s https://cloud.lab.networktechguy.com/status.php-"maintenance":falseand the same version as before - From Traefik:
curl -s -H "Host: cloud.lab.networktechguy.com" http://10.20.0.91/status.phpon the Traefik host - Setup checks:
sudo -u www-data php occ setupchecks- nothing to fix - Log:
/var/log/nextcloud/nextcloud.log- no new errors - Clients: the desktop and phone apps sync again without signing in anew - you should see their WebDAV requests in
/var/log/nginx/access.logwithin a minute - Sign-in: single sign-on (SAML/OIDC) works - it uses the public name, so nothing changes there
- Office documents: open one - the office server also talks to Nextcloud through its public name
- Background jobs: Administration settings -> Basic settings shows the last cron run a few minutes ago
- Antivirus:
systemctl is-active clamav-daemon - Nightly dump:
sudo /usr/local/sbin/nc-db-dump && sudo ls -l /var/backups/nextcloud
Common Issues #
occ fails with "RedisException: No such file or directory": the copied config.php still points at the old Redis socket (/run/redis/redis-server.sock). Fix the redis entry by hand - occ can't fix it for you, because it needs Redis to start.
"Your data directory is not writable" during the rehearsal: the share is mounted read-only. Use the overlay mount from step 10 - never mount it read-write on both servers.
SSH: "Received disconnect: Too many authentication failures": your SSH client offered more keys than the server allows (6 by default) before it got to the password. This happens on a brand new server where your key isn't installed yet - use ssh -o PubkeyAuthentication=no user@host for the first login.
adduser / usermod / visudo: command not found: you became root with su instead of su -, so /usr/sbin isn't in your PATH. Use su -.
The setup checks complain about HSTS: while you test from the server itself, the request doesn't go through Traefik, which is where the header is set. Run the check again after the cutover.
The gateway doesn't answer ping after the IP change: many firewalls don't reply to ping on their interfaces. Test with real traffic instead, e.g. curl -I https://deb.debian.org.
The public address times out for a minute after the switch: ARP caches on the gateway or the Traefik host still point at the old server's MAC address. The unsolicited ARP in the switch script fixes this; without it, wait a minute or two.
php-imap can't be installed: PHP 8.4 removed the IMAP extension. Only the Mail app and IMAP sign-in need it.
Rollback #
Until the old server is shut down, rolling back is just occ maintenance:mode --off and starting nginx and PHP-FPM on it again. After the IP switch: shut the new server down and start the old one - its disk and its database were never touched. We keep the old VM switched off for a couple of weeks before deleting it.
Wrapping up #
The whole migration came down to 9 minutes of downtime, and most of that was waiting for dumps and copies. The real work happened before the cutover - looking at what the old server actually did, building the new one properly, and rehearsing against real data without any risk to the running instance.
If you set up your Nextcloud by following our earlier articles (Install and configure Nextcloud in an LXC - Part 1 and Part 2), this is a good way to bring it onto a current system. And if you keep upgrading Nextcloud from the command line, How to manually upgrade Nextcloud still applies on Debian - only the PHP binary is now php8.4.
Comments (0)
No comments yet. Be the first to comment.
Leave a comment
Privacy: your name and comment are shown once published. Your e-mail address is never shown or passed on; it is used for the confirmation link and to recognise your later comments, and is deleted when you have no comments left here. Comments that are never confirmed are deleted after 7 days. Confirming sets a cookie in this browser so that it does not ask again.