|
|
||
|---|---|---|
| .forgejo/workflows | ||
| LICENSE | ||
| README.md | ||
Self-Hosted Forgejo via Rootless Podman on RHEL 10
Deployment guide for a self-hosted Forgejo instance running as rootless Podman containers behind an Nginx reverse proxy with SSL termination on Red Hat Enterprise Linux 10. SSH passthrough is configured so git clone/push/pull works over port 22 without exposing the container directly.
Table of Contents
Part 1 — Forgejo Server
- Architecture
- Prerequisites
- 1. Install Packages
- 2. Port Forwarding
- 3. Create the
gitUser - 4. Directory Structure
- 5. Quadlet Files
- 6. Nginx Config
- 7. SSL Certificates
- 8. Secrets
- 9. Auto-Updates
- 10. Start Everything
- 11. SSH Passthrough
- 12. SELinux
- Accessing the
gitUser After Setup - Verification
Part 2 — Forgejo Actions Runner (Optional)
- 13. Loopback Port Forwarding
- 14. SELinux — Runner Socket Access
- 15. Enable Actions in Forgejo
- 16. Pod DNS Override
- 17. Runner Configuration
- 18. Register the Runner
- 19. Runner Container
- 20. Verify the Runner
Credits
Part 1 — Forgejo Server
Architecture
Internet -> :443 (firewalld forwards to :8443) -> Pod [Nginx :443 -> Forgejo :3000]
[MariaDB :3306 ]
SSH :22 -> host sshd -> AuthorizedKeysCommand -> forgejo-server container
Three containers in one pod:
- forgejo-server — the Forgejo application
- forgejo-db — MariaDB backend
- forgejo-proxy — Nginx reverse proxy handling TLS
Everything runs under a dedicated git user with no sudo privileges.
Prerequisites
- RHEL 10 with an active subscription
- A domain pointing to your server (replace
forgejo.example.comthroughout) - SSL certificate + key for that domain (e.g. from Let's Encrypt)
- A sudo-capable user for initial setup (not
git)
1. Install Packages
sudo dnf install podman systemd-container
2. Port Forwarding
Rootless Podman cannot bind to privileged ports. Redirect 443 → 8443 via firewalld:
# IPv4
sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
sudo firewall-cmd --permanent --add-forward-port=port=443:proto=udp:toport=8443
# IPv6
sudo firewall-cmd --permanent \
--add-rich-rule='rule family=ipv6 forward-port to-port=8443 protocol=tcp port=443'
sudo firewall-cmd --permanent \
--add-rich-rule='rule family=ipv6 forward-port to-port=8443 protocol=udp port=443'
sudo firewall-cmd --reload
3. Create the git User
The username must be git — SSH passthrough depends on it.
sudo useradd --create-home git
sudo gpasswd -a git systemd-journal
From here on, operate as the git user:
sudo machinectl shell git@ /bin/bash
4. Directory Structure
mkdir -p ~/.config/containers/systemd
mkdir -p ~/forgejo/cert
5. Quadlet Files
All files go in ~/.config/containers/systemd/.
Pod — forgejo.pod
[Unit]
Description=Forgejo pod
[Pod]
PodName=forgejo
PublishPort=8443:443
[Install]
WantedBy=default.target
Forgejo Server — forgejo-server.container
[Unit]
Description=Forgejo server
After=forgejo-db.service
[Container]
Image=codeberg.org/forgejo/forgejo:14
ContainerName=forgejo-server
Pod=forgejo.pod
Environment=FORGEJO__database__DB_TYPE=mysql
Environment=FORGEJO__database__HOST=127.0.0.1:3306
Environment=FORGEJO__database__NAME=forgejo
Environment=FORGEJO__database__USER=forgejo
Environment=FORGEJO__server__PROTOCOL=http
Environment=FORGEJO__server__ROOT_URL=https://forgejo.example.com
Environment=FORGEJO__server__HTTP_PORT=3000
Environment=FORGEJO__server__SSH_PORT=22
Environment=FORGEJO__server__SSH_CREATE_AUTHORIZED_KEYS_FILE=false
Secret=forgejo_mariadb_password,type=env,target=FORGEJO__database__PASSWD
Volume=forgejo-data.volume:/data:Z
Volume=%h/forgejo/timezone:/etc/timezone:ro
Volume=/etc/localtime:/etc/localtime:ro
AutoUpdate=registry
HealthCmd=[[ "$(curl --silent --insecure --output /dev/null --write-out "%{http_code}" http://127.0.0.1:3000/api/healthz)" == '200' ]]
HealthInterval=15s
HealthTimeout=1s
HealthRetries=3
HealthOnFailure=kill
Notify=healthy
[Service]
Restart=always
RestartSec=5s
Set your timezone:
echo 'Europe/Bucharest' > ~/forgejo/timezone
Forgejo Data Volume — forgejo-data.volume
[Unit]
Description=Forgejo data volume
[Volume]
MariaDB — forgejo-db.container
[Unit]
Description=MariaDB for Forgejo
[Container]
Image=docker.io/mariadb:lts
ContainerName=forgejo-db
Pod=forgejo.pod
Environment=MARIADB_USER=forgejo
Environment=MARIADB_DATABASE=forgejo
Environment=MARIADB_AUTO_UPGRADE=1
Secret=forgejo_mariadb_password,type=env,target=MARIADB_PASSWORD
Secret=forgejo_mariadb_root_password,type=env,target=MARIADB_ROOT_PASSWORD
Volume=forgejo-db.volume:/var/lib/mysql:Z
AutoUpdate=registry
HealthCmd=healthcheck.sh --connect --innodb_initialized
HealthInterval=10s
HealthTimeout=5s
HealthRetries=3
HealthOnFailure=kill
Notify=healthy
[Service]
Restart=always
RestartSec=5s
DB Volume — forgejo-db.volume
[Unit]
Description=Forgejo DB volume
[Volume]
Nginx Proxy — forgejo-proxy.container
[Unit]
Description=Nginx reverse proxy for Forgejo
After=forgejo-server.service
[Container]
Image=docker.io/nginx:alpine
ContainerName=forgejo-proxy
Pod=forgejo.pod
Volume=%h/forgejo/nginx.conf:/etc/nginx/nginx.conf:Z,ro
Volume=%h/forgejo/cert:/etc/nginx/cert:Z,ro
AutoUpdate=registry
HealthCmd=[[ $(curl --silent --insecure --output /dev/null --head --write-out "%{http_code}" https://127.0.0.1) == "200" ]] || [[ $(curl --silent --insecure --output /dev/null --write-out "%{http_code}" https://127.0.0.1) == "200" ]]
HealthInterval=15s
HealthTimeout=1s
HealthRetries=3
HealthOnFailure=kill
Notify=healthy
[Service]
Restart=always
RestartSec=5s
6. Nginx Config
Create ~/forgejo/nginx.conf:
events { }
http {
server {
listen 443 ssl;
server_name forgejo.example.com;
ssl_certificate /etc/nginx/cert/domain.cert.pem;
ssl_certificate_key /etc/nginx/cert/private.key.pem;
location / {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
7. SSL Certificates
Place your certificate and private key at:
~/forgejo/cert/domain.cert.pem
~/forgejo/cert/private.key.pem
Lock down the key:
chmod 600 ~/forgejo/cert/private.key.pem
If using Let's Encrypt or another public CA, system-wide trust enrollment is unnecessary — the ISRG root certificates are already present in the RHEL trust store.
8. Secrets
Store DB passwords via Podman secrets (never in plaintext files or images):
read -erp "MariaDB forgejo user password: " forgejo_mariadb_password
printf "${forgejo_mariadb_password}" | podman secret create forgejo_mariadb_password -
read -erp "MariaDB root password: " forgejo_mariadb_root_password
printf "${forgejo_mariadb_root_password}" | podman secret create forgejo_mariadb_root_password -
9. Auto-Updates
The AutoUpdate=registry directive in each container file enables automatic image pulls. Activate the timer:
systemctl --user enable --now podman-auto-update.timer
10. Start Everything
systemctl --user daemon-reload
systemctl --user start forgejo-pod.service
Enable lingering so containers survive user logout:
loginctl enable-linger
11. SSH Passthrough
This allows git clone git@forgejo.example.com:user/repo.git to work over the host's port 22 by forwarding auth and shell commands into the container.
Log out of the git user first. These steps require sudo.
SSH Auth Delegation
Create /etc/ssh/sshd_config.d/60-forgejo.conf:
Match User git
AuthorizedKeysCommand /usr/bin/podman exec --user=git --interactive forgejo-server /usr/local/bin/forgejo --config /data/gitea/conf/app.ini keys -e git -u %u -t %t -k %k
AuthorizedKeysCommandUser git
Match all
sudo chmod 600 /etc/ssh/sshd_config.d/60-forgejo.conf
sudo systemctl restart sshd
Custom Shell for git User
Create /usr/local/bin/ssh-shell:
#!/bin/sh
/usr/bin/podman exec \
--user=git \
--interactive \
--env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" \
forgejo-server \
sh "$@"
Apply it:
sudo chmod +x /usr/local/bin/ssh-shell
sudo usermod --shell /usr/local/bin/ssh-shell git
12. SELinux
SELinux remains in enforcing mode throughout this deployment. On RHEL 10, no custom policy modules or boolean changes were required — all pod operations, SSH passthrough, and container health checks functioned correctly under the default SELinux policy as shipped.
The original guide (written for CentOS Stream 10) includes a custom SELinux policy module with sshd_launch_containers and other booleans. These were not necessary on RHEL 10 in testing. If you encounter AVC denials, check ausearch -m avc -ts recent and build a targeted module from there rather than importing a generic one.
Accessing the git User After Setup
Since the git user's shell is now ssh-shell, direct login is no longer possible. Use machinectl from your sudo user:
sudo machinectl shell git@ /bin/bash
Verification
# Check pod status
sudo machinectl shell git@ /bin/bash -c 'systemctl --user status forgejo-pod'
# Check all containers are healthy
sudo machinectl shell git@ /bin/bash -c 'podman ps'
# Test HTTPS
curl -I https://forgejo.example.com
# Test SSH
ssh -T git@forgejo.example.com
Part 2 — Forgejo Actions Runner (Optional)
This section adds CI/CD capability to your Forgejo instance via the Forgejo Actions runner. The runner executes workflows inside containers spawned through the host's Podman socket.
At the time of writing, runner version 12 is the latest. Check the container registry for newer releases and adjust the tag accordingly.
13. Loopback Port Forwarding
The port forwarding from Part 1 only covers traffic arriving from external interfaces. Traffic originating from localhost (which is how the runner reaches Forgejo) does not get redirected. A firewalld policy fixes this.
These commands require sudo — log out of the git user first.
sudo firewall-cmd --permanent --new-policy=lo-port-forward
sudo firewall-cmd --permanent --policy=lo-port-forward --add-ingress-zone=HOST
sudo firewall-cmd --permanent --policy=lo-port-forward --add-egress-zone=ANY
# IPv4
sudo firewall-cmd --permanent --policy=lo-port-forward \
--add-rich-rule='rule family="ipv4" destination address="127.0.0.0/8" forward-port port="443" protocol="tcp" to-port="8443" to-addr="127.0.0.1"'
sudo firewall-cmd --permanent --policy=lo-port-forward \
--add-rich-rule='rule family="ipv4" destination address="127.0.0.0/8" forward-port port="443" protocol="udp" to-port="8443" to-addr="127.0.0.1"'
# IPv6
sudo firewall-cmd --permanent --policy=lo-port-forward \
--add-rich-rule='rule family="ipv6" destination address="::1/128" forward-port port="443" protocol="tcp" to-port="8443" to-addr="::1"'
sudo firewall-cmd --permanent --policy=lo-port-forward \
--add-rich-rule='rule family="ipv6" destination address="::1/128" forward-port port="443" protocol="udp" to-port="8443" to-addr="::1"'
sudo firewall-cmd --reload
14. SELinux — Runner Socket Access
The runner needs to communicate with the Podman socket via Unix socket. On RHEL 10, this requires a small custom SELinux policy module:
sudo tee /tmp/podman_unix_socket.te << 'EOF' > /dev/null
module podman_unix_socket 1.0;
require {
type container_t;
type container_runtime_t;
type user_tmp_t;
class sock_file write;
class unix_stream_socket connectto;
}
allow container_t container_runtime_t:unix_stream_socket connectto;
allow container_t user_tmp_t:sock_file write;
EOF
sudo checkmodule -M -m -o /tmp/podman_unix_socket.mod /tmp/podman_unix_socket.te
sudo semodule_package -m /tmp/podman_unix_socket.mod -o /tmp/podman_unix_socket.pp
sudo semodule -i /tmp/podman_unix_socket.pp
Note: Unlike Part 1 where the default RHEL 10 SELinux policy covered everything, the runner's socket communication does require this custom module.
15. Enable Actions in Forgejo
Log back in as git:
sudo machinectl shell git@ /bin/bash
Edit ~/.config/containers/systemd/forgejo-server.container and add to the [Container] section:
Environment=FORGEJO__actions__ENABLED=true
Environment=FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org
16. Pod DNS Override
The runner's spawned action containers need to resolve your Forgejo FQDN to localhost. Add these lines to the [Pod] section of ~/.config/containers/systemd/forgejo.pod:
AddHost=forgejo.example.com:127.0.0.1
AddHost=forgejo.example.com:::1
Replace forgejo.example.com with your actual domain. If your host also has a short hostname that differs, add entries for that too.
Restart to apply:
systemctl --user daemon-reload
systemctl --user restart forgejo-pod.service
17. Runner Configuration
Generate the default config:
mkdir -p ~/runner/config
podman run \
--rm \
code.forgejo.org/forgejo/runner:12 \
forgejo-runner generate-config > ~/runner/config/config.yml
Edit ~/runner/config/config.yml and make these changes under the container: section:
- Set
network:to"host" - Add the following to
options::
--add-host=forgejo.example.com:127.0.0.1
--add-host=forgejo.example.com:::1
Replace forgejo.example.com with your actual domain.
18. Register the Runner
In the Forgejo web UI, go to Site Administration → Actions → Runners → Create new runner and copy the registration token.
read -erp "Registration token: " registration_token
read -erp "Runner name: " runner_name
read -erp "Forgejo URL (e.g. https://forgejo.example.com): " forgejo_url
podman run \
--user 0:0 \
--rm \
--tty \
--interactive \
--pod forgejo \
--volume ~/runner:/data:Z \
code.forgejo.org/forgejo/runner:12 \
forgejo-runner register \
--no-interactive \
--config /data/config/config.yml \
--token "${registration_token}" \
--name "${runner_name}" \
--instance "${forgejo_url}"
systemctl --user enable --now podman.socket
19. Runner Container
Create ~/.config/containers/systemd/forgejo-runner.container:
[Unit]
Description=Forgejo runner
After=forgejo-server.service
[Container]
Image=code.forgejo.org/forgejo/runner:12
ContainerName=forgejo-runner
Pod=forgejo.pod
User=0
Group=0
Environment=DOCKER_HOST=unix:///var/run/docker.sock
Exec=forgejo-runner --config /data/config/config.yml daemon
Volume=%h/runner:/data:Z
Volume=/run/user/%U/podman/podman.sock:/var/run/docker.sock:rw
Volume=%h/forgejo/timezone:/etc/timezone:ro
Volume=/etc/localtime:/etc/localtime:ro
AutoUpdate=registry
[Service]
Restart=always
RestartSec=5s
Start it:
systemctl --user daemon-reload
systemctl --user start forgejo-runner.service
20. Verify the Runner
Create .forgejo/workflows/demo.yml in any repository:
on: [push]
jobs:
test:
runs-on: docker
steps:
- run: echo Success
Push it. The pipeline should start and succeed. The runner should appear as online in Site Administration → Actions → Runners.
Credits
This deployment is based on the following guides by un p|pe && au lit, licensed under CC BY-SA 4.0: