This article is part of my Self-Hosting with Kicksecure series.

1. Introduction

After moving away from Kubernetes, I wanted something simpler but still declarative. Quadlets let me define containers as systemd units—a format I already know from managing other system services.

What I like about quadlets:

  • Systemd integration - Containers start on boot, restart on failure, and log to journald
  • GitOps friendly - Unit files are plain text, easy to track in version control
  • Rootless - I can run everything as a regular user with systemd user sessions
  • No docker-compose needed - Native systemd handles dependencies

2. Prerequisites

Before this, I had:

3. Directory Structure

Quadlets live in ~/.config/containers/systemd/:

/home/user/.config/containers/systemd/
├── gitea.container
├── gitea-runner.container
└── gitea.network    # Optional: custom network definition

4. Prepare Storage

Created a subvolume for Gitea data:

sudo btrfs subvolume create /mnt/storage_name/gitea_data

Set ownership for rootless Podman:

podman unshare chown -R 1000:1000 /mnt/storage_name/gitea_data

This maps the container’s UID 1000 to my user’s permissions via the ID mapping I configured earlier.

5. The Gitea Quadlet

Created ~/.config/containers/systemd/gitea.container:

[Unit]
Description=Gitea
After=network-online.target

[Container]
Image=docker.io/gitea/gitea:latest
ContainerName=gitea

# Web interface - bind to localhost only
PublishPort=127.0.0.1:3000:3000

# SSH - binds to all interfaces for external git access
PublishPort=2222:22

Environment=USER_UID=1000
Environment=USER_GID=1000

Volume=/mnt/storage_name/gitea_data:/data:Z

[Service]
Restart=always

[Install]
WantedBy=default.target

5.1. Key Points

PublishPort 127.0.0.1:3000:3000 - Binds the web interface to localhost only. External access goes through my reverse proxy or VPN.

PublishPort 2222:22 - No IP specified means it binds to all interfaces. This allows git operations over SSH from external hosts.

Volume with :Z - The Z suffix tells Podman to relabel the SELinux context (even though Kicksecure uses AppArmor, Podman still recognizes this flag for private volumes).

6. Enable the Podman Socket

Quadlets require the Podman socket for user sessions:

systemctl --user enable --now podman.socket

This creates /run/user/1000/podman/podman.sock.

7. Starting Gitea

Reloaded systemd to recognize the quadlet, then started the service:

systemctl --user daemon-reload
systemctl --user enable --now gitea.service

Checked status:

systemctl --user status gitea.service

Viewed logs:

journalctl --user -u gitea.service

8. Gitea Runner for CI/CD

To run Gitea Actions, I deployed the act_runner container. Created ~/.config/containers/systemd/gitea-runner.container:

[Unit]
Description=Gitea Actions Runner
After=network-online.target gitea.service
Requires=podman.socket

[Container]
Image=docker.io/gitea/act_runner:latest
ContainerName=gitea-runner

# Host network to reach gitea at localhost
Network=host

# Pass the podman socket for Docker-compatible CI
Volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock:Z

Volume=/mnt/storage_name/app_cache/gitea_runner:/data:Z

Environment=GITEA_INSTANCE_URL=http://127.0.0.1:3000
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=YOUR_REGISTRATION_TOKEN
Environment=DOCKER_HOST=unix:///var/run/docker.sock

[Service]
Restart=always

[Install]
WantedBy=default.target

Got the registration token from Gitea: Site Administration → Actions → Runners → Create new Runner.

8.1. Why Host Networking?

The runner uses Network=host so it can reach Gitea at localhost:3000 without network configuration. Simpler than creating a dedicated pod network.

8.2. Podman Socket Passthrough

The runner mounts the host’s Podman socket, allowing CI pipelines to build containers using the host’s Podman instance. This is powerful but has security implications—only use for trusted CI workloads.

9. Accessing Gitea

  • Web UI: http://localhost:3000 (or through reverse proxy)
  • SSH Git: git clone ssh://git@your-server:2222/username/repo.git

Configured my SSH client to use port 2222:

Host gitea
    HostName your-server
    Port 2222
    User git

10. Managing Containers

Common systemd commands I use:

# View status
systemctl --user status gitea.service
systemctl --user status gitea-runner.service

# Restart
systemctl --user restart gitea.service

# Stop
systemctl --user stop gitea.service

# View logs
journalctl --user -u gitea.service -f

# List containers
podman ps

11. Tracking Quadlets in Git

Since quadlets are just text files, I version control them:

cd ~/.config/containers/systemd
git init
git add *.container *.network
git commit -m "Initial container definitions"

This makes it easy to manage multiple services and push to a central repository.

12. Troubleshooting

Container Won’t Start

Check logs:

journalctl --user -u gitea.service -n 50

Common issues:

  • Permission denied on volume: Re-run podman unshare chown
  • Image pull failed: Check network access to docker.io
  • Port already in use: Something else is using 3000 or 2222

Quadlet Not Recognized

Ensure the file ends in .container and is in the correct directory:

ls -la ~/.config/containers/systemd/

Reload systemd:

systemctl --user daemon-reload

Firewall Issues

Locked Out

If I lock myself out:

  1. Physical console access
  2. Recovery mode
  3. Reset UFW: ufw reset

Rules Not Applied

Reload the firewall:

ufw reload

Service Still Accessible

I check if the service is bound to the correct interface:

ss -tlnp

A service bound to 0.0.0.0 listens on all interfaces. One bound to 127.0.0.1 only listens on localhost.

13. UFW Firewall Configuration

I chose UFW (Uncomplicated Firewall) for its simple interface to iptables. While Kicksecure has some firewall defaults, I wanted explicit control over my network security—especially for my Podman container services.

13.1. Installation

apt install ufw

I also installed the GUI for easier visualization:

apt install gufw

13.2. Default Policy

I set a default-deny policy—block all incoming, allow all outgoing:

ufw default deny incoming
ufw default allow outgoing

This means only ports I explicitly open are accessible.

13.3. Enable the Firewall

ufw enable

Then enabled the systemd service:

systemctl enable ufw.service
systemctl start ufw.service

13.4. SSH Access

Since I manage this server remotely, I needed SSH access before enabling the firewall:

ufw allow ssh

Or specifying the port directly:

ufw allow 22/tcp

13.5. Gitea Ports

For Gitea, I opened port 2222 for SSH git operations:

ufw allow 2222/tcp

The web interface (port 3000) is bound to localhost in my quadlet, so it doesn’t need a firewall rule. External access goes through my reverse proxy or VPN.

13.6. Restricting Access by Source

For services that shouldn’t be publicly accessible, I restricted them to specific IPs or subnets.

VPN-Only Access

I use Netbird for VPN access, so I restricted sensitive services to the VPN subnet:

ufw allow from 100.64.0.0/10 to any port 3000 proto tcp

Specific IP Access

For trusted IPs only:

ufw allow from 192.168.1.100 to any port 3000 proto tcp

Removing Rules

Remove a rule by prefixing delete:

ufw delete allow 3000/tcp

Or by number:

ufw status numbered
ufw delete 3

13.7. Checking Status

View current rules:

ufw status verbose

Output looks like:

Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)

New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere
2222/tcp                   ALLOW IN    Anywhere

13.8. Why Default-Deny Matters

A default-deny firewall gives me:

  1. Reduced attack surface - Unknown services aren’t exposed
  2. Prevention of accidents - New services aren’t accidentally exposed
  3. Limited reconnaissance - Attackers can’t discover services
  4. Simple mental model - If it’s not allowed, it’s blocked

This is especially important on a server running multiple services.

13.9. Tracking Firewall Changes

As with all system configuration, I tracked this with etckeeper:

cd /etc
git add .
etckeeper commit "Configured UFW firewall rules"

14. What’s Next

This completes the core series. Future posts will cover ZFS RAID and Ansible automation with enroll.