Forgejo installation steps
Find a file
Nika / Cojanu Ionut 886b2034cf
All checks were successful
/ test (push) Successful in 3s
feat: index
2026-03-16 09:56:20 +02:00
.forgejo/workflows chore: created file structure 2026-02-02 20:48:10 +01:00
LICENSE Initial commit 2026-02-02 21:46:27 +02:00
README.md feat: index 2026-03-16 09:56:20 +02:00

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

Part 2 — Forgejo Actions Runner (Optional)

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.com throughout)
  • 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:

  1. Set network: to "host"
  2. 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: