In this article I explain a simple how the Quadlets can be combined with systemd target unit. The target unit is handy to group systemd services (not just Quadlet generated services, but anything) together and simply start/stop/restart them on request.

About pod

If we only wants to group Quadlets, we can achieve the same with using pod. If the pod is stopped or started, all container following the way. But using pod is not always that comfortable. One of the simplest example is that I want to run multiple container that uses the same port. This is not possible within a pod, because it shares the network namespace, so the IP/MAC address and port mapping are the same. In practice, if one container binds to a port in a pod, then this port becomes unavailable.

Practical example

I convert one solution from earlier. I’ve implemented 1Password API within a pod, see details the earlier post. In this article, I convert this approach to a different one:

  • Instead of changing the application listen port, within the container, I left them on default and connect them with a network.
  • To handler start, stop and restart easier, I introduce a systemd target unit for it.

This change is not mandatory, in practice I’d stick with the pod, but sometimes it is not possible to change listen port of application on easy way. So this is rather an example, how to build a solution like this up.

Create Quadlets

I use basically four Quadlets in this solution:

  1. 1Password API
  2. 1Password sync
  3. Shared volume
  4. 1Password network

Volume and Network

They are very simple units, just very simple a basic definitions. The network definition can be seen.

[Unit]
Description=1Password network

[Network]

This is the volume unit.

[Unit]
Description=1Password disk

[Volume]

Services

The API service can be seen next.

[Unit]
Description=1Password Connect API
PartOf=1pw.target

[Container]
# Base options
AutoUpdate=registry
Image=docker.io/1password/connect-api:latest

# Storage options
Volume=1pw.volume:/home/opuser/.op/data

# Network options
Network=1pw.network
PublishPort=8888:8080

# Secret options
Secret=1pw-secret,target=/home/opuser/.op/1password-credentials.json

# Other options
UserNS=keep-id

[Install]
WantedBy=1pw.target

Comparing with the pod solution in the old post, changes are:

  • It has a dependency with 1pw.target unit via PartOf and WantedBy. Due to PartOf, when the 1pw.target is stopped or restarted, it propagates to the API unit. The WantedBy gives the start dependency, so when 1pw.target starts, then API also start.
  • The environment variables are removed, since no need to reconfigure the application.
  • Port is published in container, since it does not belong to any pod.

The sync container looks very same, except no need to publish any port.

[Unit]
Description=1Password Connect Sync
PartOf=1pw.target

[Container]
# Base options
AutoUpdate=registry
Image=docker.io/1password/connect-sync:latest

# Storage options
Volume=1pw.volume:/home/opuser/.op/data

# Network options
Network=1pw.network

# Secret options
Secret=1pw-secret,target=/home/opuser/.op/1password-credentials.json

# Other options
UserNS=keep-id

[Install]
WantedBy=1pw.target

Create target

I start these are rootless containers, so target unit must be put on user path which is ~/.config/systemd/user directory. The 1pw.target file is created, with a very simple content. The target unit does not contains any script or command. This is only used to synchronize or group systemd services.

📝 Note

The advantage to use target is that Qudlet generated systemd units can be synchronized with pure systemd services.
[Unit]
Description=Target for 1Password

Of course, if need extra dependency for the target, that can be specified in the Unit or Installation section, just like with a regular service unit.

Activate and test

Changes can be activated with a systemctl --user daemon-reload command. After it, we can see if the target is started, both containers are up and working. If the target is down, both service are down.

$ systemctl --user status 1pw.target
○ 1pw.target - Target for 1Password
     Loaded: loaded (/home/ati/.config/systemd/user/1pw.target; static)
     Active: inactive (dead)
$ systemctl --user start 1pw.target
$ podman ps
CONTAINER ID  IMAGE                                    COMMAND     CREATED        STATUS       PORTS               NAMES
d400fc867fd7  docker.io/1password/connect-sync:latest              3 seconds ago  Up 1 second                      systemd-1pw-sync
7ea0c81112dd  docker.io/1password/connect-api:latest               3 seconds ago  Up 1 second  8080/tcp, 8443/tcp  systemd-1pw-api
$ curl \
    -H "Accept: application/json" \
    -H "Authorization: Bearer $OP_API_TOKEN" \
    http://localhost:8888/v1/vaults
[{"attributeVersion":1,"contentVersion":8,"createdAt":"2025-03-03T16:10:46Z","id":"yozqzg3xbf43qcfkyi6b6znz6e","items":5,"name":"sandbox","type":"USER_CREATED","updatedAt":"2025-03-08T20:01:12Z"}]%
$ podman ps
CONTAINER ID  IMAGE                                    COMMAND     CREATED         STATUS         PORTS               NAMES
d400fc867fd7  docker.io/1password/connect-sync:latest              12 seconds ago  Up 11 seconds                      systemd-1pw-sync
7ea0c81112dd  docker.io/1password/connect-api:latest               12 seconds ago  Up 11 seconds  8080/tcp, 8443/tcp  systemd-1pw-api
$ systemctl --user stop 1pw.target
$ podman ps
CONTAINER ID  IMAGE       COMMAND     CREATED     STATUS      PORTS       NAMES

But if one service is down, it does not bring down whole group.

$ podman ps
CONTAINER ID  IMAGE                                    COMMAND     CREATED         STATUS         PORTS                             NAMES
14a1900b6304  docker.io/1password/connect-sync:latest              17 minutes ago  Up 17 minutes                                    systemd-1pw-sync
497108b8b730  docker.io/1password/connect-api:latest               17 minutes ago  Up 17 minutes  0.0.0.0:8888->8080/tcp, 8443/tcp  systemd-1pw-api
$ systemctl --user stop 1pw-api
$ podman ps
CONTAINER ID  IMAGE                                    COMMAND     CREATED         STATUS         PORTS       NAMES
14a1900b6304  docker.io/1password/connect-sync:latest              18 minutes ago  Up 18 minutes              systemd-1pw-sync

Start after boot

To start a target after boot can be done by specify an option in its Install section. For example, WantedBy with the default.target (just like in case of Quadlet). But, not like with Quadlets, it must be enabled.

$ cat 1pw.target
[Unit]
Description=Target for 1Password

[Install]
WantedBy=default.target
$ systemctl --user enable --now 1pw.target
Created symlink '/home/ati/.config/systemd/user/default.target.wants/1pw.target''/home/ati/.config/systemd/user/1pw.target'.

Final words

This can be a way how to start and stop systemd services together. It might seems more complicated, comparing with a compose file, but it has the advantage to bundle Quadlet generated services (even multiple pods) together with pure systemd services (services, timers, paths, etc.). In my opinion, using pod is an easier approach, but if we stuck on an issue, like the port conflict, this can be an alternatives.