Gitea with Podman Quadlets on Kicksecure
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:
- Rootless Podman configured (Podman Permissions on Kicksecure)
- Encrypted storage mounted (Encrypted BTRFS Storage on Kicksecure)
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:
- Physical console access
- Recovery mode
- 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:
- Reduced attack surface - Unknown services aren’t exposed
- Prevention of accidents - New services aren’t accidentally exposed
- Limited reconnaissance - Attackers can’t discover services
- 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.