Posted on ::

podman logo 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:

  1. Docker uses an extra daemon to orchestrate the containers whereas Podman’s “daemon” is systemd itself, automatically resulting in a seamless system integration.
  2. 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.
  3. 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)

1

There are more: See here.

2

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.

cat cat

You find everything packed as an Ansible role at the very end.

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:

  1. caddy comes with CAP_NET_BIND_SERVICE privilege out-of-the-box.
  2. It handles all Let’s Encrypt certificate handling automagically.
  3. 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:

  1. Nextcloud itself
  2. A database: I choose MariaDB
  3. 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:

  1. One internal network connecting only these three services: nextcloud-internal
  2. 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


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.volume to /var/lib/mysql within the container. nextcloud-db.volume will be located at ~/.local/share/containers/storage/volumes/systemd-nextcloud-db on the host (cf. Naming Conventions).

  • L10: %h is short for ~/ and EnvironmentFile hands over environment variables that will be exposed to the process. The environment variable names, like MYSQL_ROOT_PASSWORD from 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.

cat cat

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 24

Cron 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.timer

Enable 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.timer

Final 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.