Install Nextcloud on Ubuntu 24.04 LTS the right way
Most broken Nextcloud servers are not broken in an interesting way. Background jobs never ran, file locking sits in the database, the data directory is inside the web root, and nobody has ever restored a backup. Ubuntu 24.04 LTS removes some of the excuses: PHP 8.3 and MariaDB 10.11 are both in the archive, so no third party PPA is needed. Here is the install we do on a clean 24.04 machine, with the parts that usually go wrong marked as they come up.
Sizing and prerequisites
Nextcloud is not CPU hungry until you switch on preview generation and full text search. For 10 to 25 people working with documents, 2 vCPU and 4 GB of RAM is honest; add a core before you point it at a photo library, because every thumbnail is an ImageMagick run. Versions and the trashbin keep old copies, so plan for double the storage users think they need. A VPS with a resizable disk is the normal home for this, and past a few terabytes a dedicated server with local disks is cheaper per gigabyte.
Get DNS and a certificate working first. The setup checks test HTTPS and headers, so doing TLS up front saves you chasing warnings that are not about Nextcloud at all.
PHP 8.3 FPM and the extensions Nextcloud checks for
apt update
apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-gd php8.3-curl php8.3-mbstring php8.3-xml php8.3-zip php8.3-intl php8.3-bcmath php8.3-gmp php-apcu php-redis php-imagick libmagickcore-6.q16-7-extra unzip
Both naming styles are real in the Ubuntu archive. Every extension has a versioned package, php8.3-gd but also php8.3-apcu, php8.3-redis and php8.3-imagick. The unversioned php-apcu, php-redis and php-imagick are thin dependency packages that follow whatever the archive calls the default PHP, which on 24.04 is 8.3: php-apcu depends on php-common and php8.3-apcu. Either name installs the same code. What actually trips people is copying a package set out of a guide written for the ondrej PPA, where several PHP versions live side by side and the unversioned name follows a default you did not pick. On a plain 24.04 machine with one PHP, write the version out if you want the command to still be correct after you add a second one.
libmagickcore-6.q16-7-extra silences the "Module php-imagick has no SVG support" warning, and nothing pulls it in for you. Watch the digit: 22.04 shipped the codecs as libmagickcore-6.q16-6-extra, and between 22.04 and 24.04 the library soname went from 6 to 7. The old name survives in 24.04 only as a virtual package, so apt still resolves it with a "Note, selecting ... instead of ..." line rather than failing, but that is a courtesy that ends the moment a second provider appears. Check what actually loaded:
php -m | grep -E 'apcu|redis|imagick|intl|gmp|bcmath|zip'
php-fpm8.3 -t
systemctl restart php8.3-fpm
MariaDB with a charset that will not bite you later
apt install -y mariadb-server
mariadb-secure-installation
Root authenticates over the unix socket, so sudo mariadb gets you a prompt with no password.
CREATE DATABASE nextcloud CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
CREATE USER 'nextcloud'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON nextcloud.* TO 'nextcloud'@'localhost';
FLUSH PRIVILEGES;
Server side settings go in /etc/mysql/mariadb.conf.d/60-nextcloud.cnf:
[mysqld]
character_set_server = utf8mb4
collation_server = utf8mb4_bin
innodb_file_per_table = 1
innodb_default_row_format = dynamic
transaction_isolation = READ-COMMITTED
utf8mb4_bin is what the Nextcloud admin manual asks for, and a binary collation is the safer default anyway: it compares byte for byte, so strings that differ only in case, tokens and identifiers among them, never quietly compare equal. utf8mb4 itself matters for a duller reason: without it, a filename with an emoji fails to save.
Do not copy innodb_large_prefix or innodb_file_format out of an old tutorial, and note that the upstream page still lists both. MariaDB removed them years ago, and 10.11 refuses to start on an unknown variable, which stays a mystery until you read journalctl -u mariadb -n 50.
Take the release from Nextcloud, not from the archive
Ubuntu 24.04 has no Nextcloud server package, only the desktop client. The upstream snap is fine as an appliance, but it carries its own web server, PHP and database, and you tune only what it exposes. Unpack the release archive instead: you upgrade when you decide to, and every path matches the upstream documentation.
cd /tmp
curl -O https://download.nextcloud.com/server/releases/latest.zip
curl -O https://download.nextcloud.com/server/releases/latest.zip.sha256
sha256sum --ignore-missing -c latest.zip.sha256 && unzip -q latest.zip -d /var/www/
chown -R www-data:www-data /var/www/nextcloud
--ignore-missing is not decoration. That checksum file lists two names, latest.zip and latest.metadata, and we download only the first, so a plain sha256sum -c prints latest.metadata: FAILED open or read and exits 1, which in an integrity step reads exactly like a tampered download. With the flag you get one line, latest.zip: OK. It still fails when it should: if the zip is missing or does not match, coreutils 9.4 on 24.04 prints no file was verified or FAILED and exits non zero. If you would rather keep the plain form, download latest.metadata alongside the zip. The && is there for the same reason: pasted as a block, a failed check would otherwise scroll past and the unzip would run anyway.
A checksum only proves the download was not truncated. For provenance, fetch latest.zip.asc and the Nextcloud signing key, then gpg --verify.
NGINX or Apache
Both work. The advice that saves the most time is the same either way: take the sample config from the Nextcloud admin manual as it is, and change three lines.
server_name cloud.example.com;
root /var/www/nextcloud;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
Add client_max_body_size 16G; and fastcgi_read_timeout 3600; to the server block. The sample already handles .well-known for CalDAV, CardDAV, WebFinger and NodeInfo, and returns 404 for /config, /data, /lib and /3rdparty. Hand rolled configs are why people see the well known warning, and in bad cases why a data directory ends up readable from outside.
On Apache with PHP FPM:
a2enmod rewrite headers env dir mime setenvif proxy_fcgi
a2enconf php8.3-fpm
systemctl reload apache2
The vhost needs AllowOverride All, otherwise the shipped .htaccess is ignored, and that file is what does the well known rewrites and blocks the data directory.
Install with occ, not the web wizard
install -d -o www-data -g www-data -m 0770 /srv/nextcloud-data
cd /var/www/nextcloud
sudo -u www-data php occ maintenance:install --database=mysql --database-name=nextcloud --database-user=nextcloud --database-host=localhost --admin-user=admin --data-dir=/srv/nextcloud-data
Leave --database-pass and --admin-pass out. occ asks for them interactively, which keeps both out of your shell history and out of ps while the command runs. The data directory sits outside the web root deliberately, and it is the one choice that is unpleasant to revisit, so pick the filesystem you actually want now.
sudo -u www-data php occ config:system:set trusted_domains 1 --value=cloud.example.com
sudo -u www-data php occ config:system:set overwrite.cli.url --value=https://cloud.example.com
sudo -u www-data php occ config:system:set default_phone_region --value=LV
sudo -u www-data php occ status
APCu for local cache, Redis for file locking
Two different jobs. The local cache lives inside one PHP process, and APCu is right for it. File locking must be shared between every FPM worker and the cron process, so it needs Redis. Leave it unset and Nextcloud locks through the database, and users start seeing "file is locked" during large syncs.
apt install -y redis-server
usermod -aG redis www-data
Uncomment the socket lines in /etc/redis/redis.conf, the Debian packaging ships them disabled:
unixsocket /run/redis/redis-server.sock
unixsocketperm 770
systemctl restart redis-server php8.3-fpm
Then in config/config.php:
'memcache.local' => '\OC\Memcache\APCu',
'memcache.distributed' => '\OC\Memcache\Redis',
'memcache.locking' => '\OC\Memcache\Redis',
'redis' => ['host' => '/run/redis/redis-server.sock', 'port' => 0, 'timeout' => 1.5],
APCu is off for the CLI by default, so occ and cron report a missing memory cache. Add apc.enable_cli=1 to /etc/php/8.3/mods-available/apcu.ini. The FPM restart after usermod is not optional: a running process does not pick up a new group.
Memory, upload limits and OPcache
Do not edit the files in conf.d, they are symlinks into mods-available and shared with the CLI. Create /etc/php/8.3/fpm/conf.d/99-nextcloud.ini:
memory_limit = 512M
upload_max_filesize = 16G
post_max_size = 16G
max_execution_time = 3600
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.save_comments = 1
opcache.revalidate_freq = 60
Copy the same file into /etc/php/8.3/cli/conf.d/: cron.php and occ read the CLI ini, and an upgrade with 128M dies halfway. Nextcloud also ships a .user.ini in the web root that FPM honours, so if a limit refuses to change, look there. The web interface uploads in chunks, and so does rclone: its WebDAV backend uses 10 MiB chunks whenever the vendor is Nextcloud, and sends a file in one request only if you set --webdav-nextcloud-chunk-size 0. Plain WebDAV clients have no chunking at all. davfs2, a scripted curl -T and the WebDAV drive built into Windows Explorer each PUT the whole file in a single request, so these limits still have to cover your largest real file.
Background jobs belong in a systemd timer
AJAX cron only fires while somebody has a browser tab open, so trash expiry, version cleanup and notifications run at random or never. A crontab entry works; a timer also gives you logs and a status you can query. Create /etc/systemd/system/nextcloud-cron.service:
[Unit]
Description=Nextcloud cron.php
After=network.target mariadb.service
[Service]
Type=oneshot
User=www-data
ExecStart=/usr/bin/php -f /var/www/nextcloud/cron.php
And /etc/systemd/system/nextcloud-cron.timer:
[Unit]
Description=Run Nextcloud cron.php every 5 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
Unit=nextcloud-cron.service
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now nextcloud-cron.timer
sudo -u www-data php occ background:cron
systemctl list-timers nextcloud-cron.timer
journalctl -u nextcloud-cron.service -n 50
The background:cron line is the one people forget. Without it Nextcloud keeps using AJAX no matter how neatly your timer runs.
The setup warnings page, translated
On current releases occ setupchecks prints the same list in the shell, which is easier to read after an upgrade.
| Warning | What it really means | What to do |
|---|---|---|
| Missing indices, columns or primary keys | Upgrades add them lazily so a large oc_filecache is not locked mid upgrade | occ db:add-missing-indices, then db:add-missing-columns and db:add-missing-primary-keys. ALTER TABLE runs, so pick a quiet hour |
| No memory cache configured | APCu missing, or not enabled for this SAPI | Install php-apcu, set memcache.local, enable it for the CLI too |
| File locking uses the database | Locks go through MariaDB: slow, plus 423 errors during big syncs | Point memcache.locking at Redis |
| No default phone region set | Numbers written without a country code cannot be parsed | occ config:system:set default_phone_region --value=LV |
| Strict-Transport-Security not set | HSTS is a web server header, PHP will not send it | Add it in nginx or Apache, but only once every subdomain has a valid certificate: browsers cache the policy |
| .well-known URLs not resolved | Web server config, usually a hand written one | Use the upstream sample, or AllowOverride All |
| Reverse proxy headers incorrect | Every session looks like the proxy address, so rate limits misfire | Set trusted_proxies and forwarded_for_headers |
| Last background job ran hours ago | The timer is not running, or runs as the wrong user | systemctl list-timers, then the service journal |
| No maintenance window is set | Heavy jobs may start mid working day | occ config:system:set maintenance_window_start --type=integer --value=1, hour in UTC |
Where the data lives and how to move it
Paths in oc_filecache are relative to the storage that owns them, so for the user home storages, home::<user>, a move needs no rescan. It does need everything copied, including the hidden marker file that tells Nextcloud the directory is genuine: .ncdata on current releases, .ocdata on older ones. Miss it and the instance refuses to start.
sudo -u www-data php occ maintenance:mode --on
systemctl stop php8.3-fpm
rsync -aAX /var/www/nextcloud/data/ /srv/nextcloud-data/
chown -R www-data:www-data /srv/nextcloud-data
chmod 0770 /srv/nextcloud-data
Change datadirectory in config/config.php. That is not the whole job. oc_storages still holds a row with the old absolute path, and that storage is the one that owns appdata_<instanceid>: previews, avatars, theming and the dashboard. Leave it and Nextcloud registers a second storage for the new path, the copied app data is no longer mapped to its oc_filecache rows, previews and avatars quietly regenerate, and the old storage lingers. Rename it while maintenance mode is still on:
UPDATE oc_storages SET id='local::/srv/nextcloud-data/' WHERE id='local::/var/www/nextcloud/data/';
Take a database dump before you run that statement. The trailing slash is part of the id, keep it, and adjust the prefix if your tables are not the default oc_. Then check how many rows changed: zero means the id is hashed, not that the step does not apply to you. Nextcloud stores md5('local::<oldpath>/') in place of the plain string once the id would run past 64 characters, which with the local:: prefix and the trailing slash covers every data directory whose path is longer than 56 characters. Compute that md5 over the exact old string, slash included, and match on it instead. Then start FPM, turn maintenance mode off and let Nextcloud map the app data again:
sudo -u www-data php occ files:scan-app-data
Local external storage mounted by absolute path under the old directory needs its path updated too, in the admin settings or with occ files_external:list and occ files_external:config. Run occ files:scan --all only if files were added outside Nextcloud. The target filesystem must keep POSIX ownership and permissions, which rules out SMB and exFAT mounts. If the data lands under /home, check the FPM unit is not sandboxed away from it with systemctl show php8.3-fpm -p ProtectHome.
Backups you have actually restored
Three things travel together: the database, config/config.php, and the data directory. The config file holds instanceid, passwordsalt and secret, so data restored without it is a pile of files, not an instance.
sudo -u www-data php occ maintenance:mode --on
mariadb-dump --single-transaction --default-character-set=utf8mb4 nextcloud > /backup/nextcloud-$(date +%F).sql
rsync -aAX --delete /srv/nextcloud-data/ /backup/data/
cp /var/www/nextcloud/config/config.php /backup/
sudo -u www-data php occ maintenance:mode --off
mariadb-dump is the current name, mysqldump a compatibility symlink. --single-transaction is consistent for InnoDB only, so confirm nothing landed as MyISAM, and add custom_apps if you installed apps by hand.
Then test a restore on a spare machine, once a quarter. Unpack the same version you dumped and never a newer one, import the SQL, put back config.php, change trusted_domains, fix ownership, run occ maintenance:data-fingerprint so desktop clients resync, then log in and open a file. Keep one copy older than a week and one off the machine: ransomware and a quiet deletion both look exactly like a successful sync.
After that the work is unglamorous. Watch the timer, read the warnings page after each upgrade, repeat the restore test. If you would rather hand that part over, our engineers do it as IT support, on machines in our own Riga data centre.