When it comes to containers, it unfortunately has become common practice to run services with way more privileges than necessary (if not directly as root).
Docker remains the dominant player in this field but there is a new underdog and I think it’s better suited for most of the cases:
Podman (short for pod manager) is a modern systemd-based substitute for Docker that already comes pre-installed with many operating systems – and it’s rootless by design.
Podman Quadlet is the analogue of Docker Compose – currently the de facto standard for deploying multi-container applications. In the following, I describe my way to set up a Nextcloud instance using Podman Quadlets that runs exclusively with user privileges.
Introduction
Podman is a container engine designed to manage and run containers and “pods” (later) locally without root privileges.
Quadlet furthermore is a template engine that comes with Podman and translates declarative configuration files (e.g. .container, .pod, .network, or .volume1) into the systemd .service files1.
It was a standalone project until it was included into Podma/etc/caddy/Caddyfile.d/immich.caddyfile.
Podman Quadlet is daemonless in the sense that everything is a systemd service managed by systemd. A “pod” – like in Kubernetes – is a logical group of one or more containers that share resources, like storage, network or IPC namespaces.
The three main differences of Podman to Docker are:
- Docker uses an extra daemon to orchestrate the containers whereas Podman’s “daemon” is systemd itself, automatically resulting in a seamless system integration.
- Podman is rootless by design (although it can also be deployed with root privileges if really needed) whereas the Docker daemon only recently added a rootless mode.
- Podman comes with out-of-the-box SELinux integration which explicitly needs to be activated and maintained when using Docker. (See here for further information on Docker vs. Podman)
There are more: See here.
By utilizing /usr/lib/systemd/system-generators/podman-system-generator. You can always use this tool to check for formatting errors: If it translates, (at least) the syntax is correct.
Setup Description
I assume that we own the domain ourdomain.com and that the Nextcloud should be accessible via the subdomain cloud.ourdomain.com.
There is one caveat: Although web servers run perfectly fine in user space containers, user services are generally not allowed to bind to the privileged ports below 1024 (80: HTTP, 443: HTTPS).
Proxy
To nevertheless bind to the container ports 80 and 443, I use a host system installation of the web server caddy as a proxy.
I do this for three reasons:
- caddy comes with
CAP_NET_BIND_SERVICEprivilege out-of-the-box. - It handles all Let’s Encrypt certificate handling automagically.
- I can easily continue using it for other services (e.g.
somethingother.ourdomain.com).
We set up caddy further down the post.
General Architecture
For the Nextcloud, we need three services:
- Nextcloud itself
- A database: I choose MariaDB
- A cache: Redis (not strictly needed, but recommended)
Although it may seem straightforward, I will not put those services together in one pod as I don’t want to expose all of them to the outside internet (as they share the same network).
Instead I additionally create two networks:
- One internal network connecting only these three services:
nextcloud-internal - An external network exposing only the Nextcloud service to the internet:
nextcloud-frontend
The setup looks as follows:
[ Internet ]
|
| HTTPS (443)
v
+-------------------------------------------------------------+
| HOST (Host Network Namespace) |
| |
| +-----------+ |
| | Caddy | |
| +-----------+ |
| | |
| | (cloud.ourdomain.com) |
| v |
| [ localhost:8080 ] |
| | |
|===================|=========================================|
| | |
| PODMAN | |
| v |
| [ User-mode Networking: pasta ] |
| | |
| - - - - - - - - - | - - - - - - - - - - - - - - - - - - - - |
| Container Network Namespace |
| |
| [ Gateway: 10.89.0.1 ] |
| | |
| (Network: "nextcloud-frontend" - 10.89.0.0/24) |
| | |
| v |
| +-------------------+ |
| | nextcloud-app | |
| | (Port 80) | |
| +-------------------+ |
| | |
| (Network: "nextcloud-internal") |
| | |
| +--------+--------+ |
| | | |
| v v |
| +-----------------+ +-------------------+ |
| | nextcloud-redis | | nextcloud-db | |
| | (Cache) | | (MariaDB) | |
| +-----------------+ +-------------------+ |
| |
+-------------------------------------------------------------+
This way, the database is not directly exposed to the Internet (“pasta” is the user network stack of podman).
Creating the Files
For simplicity, I will keep the setup to the bare minimum. You can set everything else (RAM limit, assigned CPU cores, storage quotas, etc.) later.
The standard location of Quadlet files for unprivileged users is:
~/.config/containers/systemd
If it doesn’t exist, create it.
To furthermore keep things organized, we place everything in sub-folders:
# Quadlet files (everything but the .env files)
~/.config/containers/systemd/nextcloud
# Environment variables (.env files)
~/.config/containers/nextcloud
So if nothing else is specified, the file goes there.
Naming Conventions
A last word about the naming conventions:
Quadlet will convert every configuration file into a .service file.
To keep this neatly organized, a .container file translates:
nextcloud-app.container -> nextcloud-app.service
Everything that is not a .container file will incorporate the file ending into the file name:
nextcloud-config.volume -> nextcloud-config-volume.service
nextcloud-frontend.network -> nextcloud-frontend-network.service
[...]
If nothing else is stated1, Quadlet will also automatically generate a name from the corresponding configuration file that the podman engine refers to (e.g. podman volume list).
Do not use these names in Quadlet/.service files!
This is the basename of the configuration file with the prefix systemd-:
nextcloud-internal.network -> nextcloud-internal-network.service
with podman name "systemd-nextcloud-internal"
The idea is to be able to discern systemd-managed entities from manually started ones. E.g.:
$ podman network ls
NETWORK ID NAME DRIVER
2f259bab93aa podman bridge
9547b81b9a69 systemd-nextcloud-frontend bridge
2a48b905738e systemd-nextcloud-internal bridge
de2bfd2b763f intermediate-connector bridge
If you follow the naming convention, it is clearly visible that intermediate-connector is not a systemd managed network.
Although you can define individual names, for this tutorial I will simply stick to the actual file names, like nextcloud-internal-network.service.1
You can also give names using e.g. VolumeName=, ContainerName=, NetworkName=, etc. and refer to the respective entities using that but thats outside the scope of this post.
The Network
To create the networks, we just create the following files:
nextcloud-internal.network
[Unit]
Description=Internal Network for Nextcloud app and DB
[Network]
Internal=true
nextcloud-frontend.network
[Unit]
Description=Frontend Network for Nextcloud app
The networks don’t exist yet. They will later be created on the fly when we later start the respective service.
The Database
Create the following three files:
nextcloud-db.env – the database name and login credentials.
MYSQL_DATABASE=nextcloud
MYSQL_USER=nextcloud
MYSQL_PASSWORD='SUPERSAFEPASSWORD1'
MYSQL_ROOT_PASSWORD='SUPERSAFEPASSWORD2'
nextcloud-db.volume - a persistent data volume
[Unit]
Description=Nextcloud MariaDB -- Data Volume
nextcloud-db.container – the actual database service.
[Unit]
Description=Nextcloud MariaDB
After=nextcloud-internal-network.service
Requires=nextcloud-internal-network.service
[Container]
Image=docker.io/library/mariadb:12
Network=nextcloud-internal.network
Volume=nextcloud-db.volume:/var/lib/mysql
EnvironmentFile=%h/.config/containers/systemd/nextcloud/nextcloud-db.env
AutoUpdate=registry
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
This defines our complete database / MariaDB container. Most of the lines are self-explanatory. I would just like to elaborate on the four highlighted lines:
-
L7: fixes the major version to 12 and constrains Quadlets auto-update feature to minor updates (12.1, 12.2, etc.). Before you do major version updates, have a look on the internet whether this breaks something / whether you have to do something manually.
-
L9: mounts the defined volume
nextcloud-db.volumeto/var/lib/mysqlwithin the container.nextcloud-db.volumewill be located at~/.local/share/containers/storage/volumes/systemd-nextcloud-dbon the host (cf. Naming Conventions). -
L10:
%his short for~/andEnvironmentFilehands over environment variables that will be exposed to the process. The environment variable names, likeMYSQL_ROOT_PASSWORDfrom above, are the ones that MariaDB expects and can not be chosen freely. -
L11: This enables the auto-update feature for this container.
Redis
nextcloud-redis.container
[Unit]
Description=Nextcloud Redis
After=nextcloud-internal-network.service
Requires=nextcloud-internal-network.service
[Container]
Image=docker.io/library/redis:alpine
Network=nextcloud-internal.network
AutoUpdate=registry
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
There is nothing new here.
Nextcloud
nextcloud-html.volume
[Unit]
Description=Nextcloud core html volume
nextcloud-config.volume
[Unit]
Description=Nextcloud config volume
nextcloud-data.volume
[Unit]
Description=Nextcloud data volume
app.env
# DB-Connection
MYSQL_DATABASE=nextcloud
MYSQL_USER=nextcloud
MYSQL_PASSWORD='SUPERSAFEPASSWORD1'
MYSQL_HOST=systemd-nextcloud-db
MYSQL_PORT=3306
# Nextcloud-Basis-URL (for links, occ, etc.)
OVERWRITEHOST=cloud.ourdomain.com
OVERWRITEPROTOCOL=https
# Trusted domain + Auto-Setup Admin
NEXTCLOUD_TRUSTED_DOMAINS=cloud.ourdomain.com
NEXTCLOUD_ADMIN_USER=root
NEXTCLOUD_ADMIN_PASSWORD='SUPERSAFEPASSWORD3'
PHP_MEMORY_LIMIT=1024M
# Redis (Caching and File-Locking)
REDIS_HOST=systemd-nextcloud-redis
NEXTCLOUD_TRUSTED_DOMAINS=cloud.ourdomain.com
Adapt the highlighted lines and notice the podman names on line 5 and 20 (it’s not a systemd service file).
nextcloud-app.container
[Unit]
Description=Nextcloud application
After=nextcloud-db.service nextcloud-redis.service
Requires=nextcloud-db.service nextcloud-redis.service
[Container]
Image=docker.io/library/nextcloud:33-apache
Network=nextcloud-internal.network
Network=nextcloud-frontend.network
Volume=nextcloud-html.volume:/var/www/html
Volume=nextcloud-config.volume:/var/www/html/config
Volume=nextcloud-data.volume:/var/www/html/data
EnvironmentFile=%h/.config/containers/nextcloud/app.env
# Bind localhost-facing port 8080 to container port 80 (web server)
# Prevents direct access to the container from the internet
PublishPort=127.0.0.1:8080:80
AutoUpdate=registry
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
In our final configuration file, we connect the two networks, the three volumes and are done.
Caddy
This is the only time we need root access:
Make sure caddy is installed (dnf install caddy) and create the following file:
/etc/caddy/Caddyfile.d/nextcloud.caddyfile
# Redirect all www. traffic to https://
www.cloud.ourdomain.com {
redir https://cloud.ourdomain.com{uri} permanent
}
cloud.ourdomain.com {
# activate zstd compression (with gzip fallback) for text-based responses (HTML, CSS, JS, JSON, SVG, etc.)
encode
reverse_proxy localhost:8080
header {
Strict-Transport-Security "max-age=31536000;"
}
}
Line 10 connects the traffic of cloud.ourdomain.com to localhost port 8080 (what’s being forwarded to container port 80 by nextcloud-app.container) and line 13 finally enforces an SSL connection.
Until everything is fully working, you might additionally add the following to the top of your main Caddy configuration /etc/caddy/Caddyfile:
# Activate Let's Encrypt test certificate until everything works.
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
This activates taking a testing SSL certificate to avoid running into Let’s Encrypt’s rate limits.
When everything works, you can remove this section, restart caddy (sudo systemctl restart caddy) and caddy will set up a proper certificate within seconds.
Finalize
To generate the .service files and start Nextcloud, simply use:
systemctl --user daemon-reload
systemctl --user start nextcloud-app
After some moments (the images have to be downloaded and extracted) the page should be accessible on cloud.ourdomain.com.
The other services are started and mounted automatically since they are referenced dependencies in the .service file.
Ensure that your firewall allows HTTPS traffic:
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
To (auto-)start the Nextcloud on boot:
loginctl enable-linger $USER
systemctl --user enable --now nextcloud-app
You might want to execute some maintenance commands on the fresh Nextcloud to remove some of the warnings on the security status page:
podman exec --user www-data systemd-nextcloud-app php occ db:add-missing-indices
podman exec --user www-data systemd-nextcloud-app php occ maintenance:repair --include-expensive
And you might also want to limit access to the system status API by setting an access token:
podman exec --user www-data systemd-nextcloud-app php occ config:app:set serverinfo token --value <token>
Generate the token e.g. by running:
openssl rand -hex 24Cron Timer
The recommended default setting for background jobs (Administration settings -> Basic settings) is to use Cron.
For that, simply create the two files
~/.config/systemd/user/nextcloud-cron.timer
[Unit]
Description=Run Nextcloud cron.php every 5 minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
~/.config/systemd/user/nextcloud-cron.service
[Unit]
Description=Nextcloud cron.php job
Requires=nextcloud-app.service
[Service]
Type=oneshot
ExecStart=/usr/bin/podman exec --user www-data systemd-nextcloud-app php -f /var/www/html/cron.php
and enable it:
systemctl --user enable --now nextcloud-cron.timerEnable Auto-Updates
Since we added AutoUpdate=registry to our container definitions, we should enable the corresponding systemd timer to regularly check for and apply image updates:
systemctl --user enable --now podman-auto-update.timerFinal Thoughts
For even greater security, you might want to run the containers on an isolated user account (say container) with completely disabled SSH login.
So, even when someone finds their way onto the machine and furthermore manages to break out the container, he/she is still limited to an otherwise empty, unprivileged user account.
For maintenance, you can still log into your other account and subsequently switch over to the container user via machinectl shell container@ – or directly manage the units using sudo systemctl --machine=container@ --user start nextcloud-app.
Appendix
For ease of installation, you can download everything as an Ansible role here.
You find everything packed as an Ansible role at the very end.