| |

Installing Forgejo on Debian 13 in an Incus Container Behind Nginx

Forgejo is the software forge I keep coming back to when a project needs its own git server. It is a single Go binary, it runs happily on modest ARM64 hardware, and it does not drag Docker, Kubernetes or a JavaScript build chain into your infrastructure. For a small development team that wants its source code on European hardware under its own control, it is close to ideal.

This guide walks through a complete Forgejo installation on Debian 13 inside an Incus container, with PostgreSQL as the database, systemd hardening and Nginx terminating TLS in front of it. It also covers the one failure mode that traps most people on their first attempt, a startup loop complaining that app.ini is on a read only file system.

I have been running self hosted infrastructure since the late 1990s, first as a hosting provider and later as an MSP, and my current production stack is built entirely on Debian and Incus containers. Everything below comes from a real deployment, including the parts that went wrong.

What we are building

A single purpose container, git01, holding Forgejo and its PostgreSQL database. Nothing else lives in it. TLS terminates on a separate Nginx container that already fronts a number of other sites and reaches Forgejo over the internal bridge.

Throughout this guide the container address is 10.0.0.20, the Nginx container is 10.0.0.10 and the hostname is git.example.com. Substitute your own.

Two design choices worth explaining before we start.

PostgreSQL over SQLite. The Forgejo documentation is right that SQLite is fine for small instances, and for two or three developers pushing code it genuinely is. The line for me is Forgejo Actions. Once you enable CI, job status updates, log chunks and artifact metadata all write concurrently to the same file and database is locked starts appearing under load. Migrating a live instance later is a dump and restore exercise you would rather not schedule. PostgreSQL costs a few hundred megabytes of RAM and removes the question entirely.

Versioned release directories. Rather than dropping the binary straight into /usr/local/bin, each release gets its own directory and a symlink points at the current one. Upgrades become atomic and rollback is a symlink change. This pattern costs nothing and has saved me more than once.

Choosing a version

Forgejo publishes stable releases quarterly, with an LTS designation on selected versions. At the time of writing v15.0 is the LTS, supported until July 2027, while v16.0 is the current stable. For a server holding production source code I take the LTS every time. Check the releases page for the newest patch level in the series before you begin.

Creating the container

On the Incus host, with a fixed address on the bridge so the Nginx configuration has something stable to point at:

incus launch images:debian/13 git01 -d eth0,ipv4.address=10.0.0.20
incus config set git01 limits.memory=2GiB limits.cpu=2
incus shell git01

Inside the container:

apt update && apt -y full-upgrade
apt -y install git git-lfs curl xz-utils gnupg postgresql ca-certificates
timedatectl set-timezone Europe/Brussels

The service user and directory layout

adduser --system --group --home /var/lib/forgejo --shell /bin/bash git

mkdir -p /var/lib/forgejo/{custom,data,log}
mkdir -p /etc/forgejo
mkdir -p /opt/forgejo/releases

chown -R git:git /var/lib/forgejo
chmod 750 /var/lib/forgejo
chown root:git /etc/forgejo
chmod 770 /etc/forgejo

Most Forgejo documentation puts the git user’s home at /home/git. I use /var/lib/forgejo instead, which lets the systemd unit set ProtectHome=true without exceptions. It is a small thing but the hardening comes out cleaner.

Installing the binary

Forgejo ships signed release binaries. Verify the signature, this is your source code repository and it deserves the two extra commands. Note the architecture suffix, arm64 here because the host is ARM. Use amd64 on x86 hardware.

FJ_VER=15.0.2
cd /tmp

curl -fLO https://codeberg.org/forgejo/forgejo/releases/download/v${FJ_VER}/forgejo-${FJ_VER}-linux-arm64.xz
curl -fLO https://codeberg.org/forgejo/forgejo/releases/download/v${FJ_VER}/forgejo-${FJ_VER}-linux-arm64.xz.asc

gpg --keyserver keys.openpgp.org --recv EB114F5E6C0DC2BCDD183550A4B61A2DC5923710
gpg --verify forgejo-${FJ_VER}-linux-arm64.xz.asc forgejo-${FJ_VER}-linux-arm64.xz

unxz forgejo-${FJ_VER}-linux-arm64.xz
mkdir -p /opt/forgejo/releases/${FJ_VER}
install -o root -g root -m 0755 forgejo-${FJ_VER}-linux-arm64 /opt/forgejo/releases/${FJ_VER}/forgejo
ln -sfn /opt/forgejo/releases/${FJ_VER} /opt/forgejo/current
ln -sfn /opt/forgejo/current/forgejo /usr/local/bin/forgejo

forgejo --version

The signing key is rotated periodically, so fetch it fresh rather than relying on a cached copy.

PostgreSQL

FJ_DBPASS=$(openssl rand -hex 24)
echo "$FJ_DBPASS"

sudo -u postgres psql <<EOF
CREATE ROLE forgejo WITH LOGIN PASSWORD '${FJ_DBPASS}';
CREATE DATABASE forgejo WITH OWNER forgejo TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'C';
EOF

Use hex rather than base64 for the password. A base64 string can contain characters that need escaping when you later paste it into a connection URL or a sed expression, and debugging that is not how you want to spend your afternoon.

Debian’s default pg_hba.conf already permits scram-sha-256 authentication from 127.0.0.1, so no changes are needed there. A little tuning is worthwhile:

cat > /etc/postgresql/17/main/conf.d/forgejo.conf <<'EOF'
shared_buffers = 256MB
effective_cache_size = 768MB
work_mem = 8MB
maintenance_work_mem = 128MB
EOF
systemctl restart postgresql

Generating the secrets

This is where the installation usually goes wrong, so read this section carefully.

Forgejo needs four secrets in its configuration. If any of them is missing or invalid, Forgejo generates a replacement at startup and writes it back into app.ini. That behaviour is convenient for a web installer and disastrous for a headless install with a read only configuration directory. Generate all four properly and the problem never appears.

SECRET_KEY=$(forgejo generate secret SECRET_KEY)
INTERNAL_TOKEN=$(forgejo generate secret INTERNAL_TOKEN)
JWT_OAUTH=$(forgejo generate secret JWT_SECRET)
JWT_LFS=$(forgejo generate secret JWT_SECRET)

The two JWT secrets must be different values. One is for OAuth2, the other for Git LFS.

Keep these in the same shell session you use to write the configuration file, or write them down. Losing them between commands is exactly how you end up with empty assignments in app.ini.

The configuration file

Write app.ini with the secrets substituted. This heredoc is deliberately unquoted so the shell expands the variables, which is the opposite of what most heredoc examples do:

cat > /etc/forgejo/app.ini <<EOF
APP_NAME = Example Code
RUN_USER = git
RUN_MODE = prod
WORK_PATH = /var/lib/forgejo

[server]
PROTOCOL = http
HTTP_ADDR = 10.0.0.20
HTTP_PORT = 3000
DOMAIN = git.example.com
ROOT_URL = https://git.example.com/
APP_DATA_PATH = /var/lib/forgejo/data
DISABLE_SSH = true
LFS_START_SERVER = true
LFS_JWT_SECRET = ${JWT_LFS}
OFFLINE_MODE = true
LANDING_PAGE = login

[database]
DB_TYPE = postgres
HOST = 127.0.0.1:5432
NAME = forgejo
USER = forgejo
PASSWD = ${FJ_DBPASS}
SCHEMA = public
SSL_MODE = disable
LOG_SQL = false

[repository]
ROOT = /var/lib/forgejo/data/forgejo-repositories
DEFAULT_PRIVATE = private
DEFAULT_PUSH_CREATE_PRIVATE = true
DISABLE_HTTP_GIT = false

[lfs]
PATH = /var/lib/forgejo/data/lfs

[security]
INSTALL_LOCK = true
SECRET_KEY = ${SECRET_KEY}
INTERNAL_TOKEN = ${INTERNAL_TOKEN}
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = 10.0.0.10
PASSWORD_HASH_ALGO = argon2

[oauth2]
JWT_SECRET = ${JWT_OAUTH}

[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = true
DEFAULT_KEEP_EMAIL_PRIVATE = true

[service.explore]
REQUIRE_SIGNIN_VIEW = true

[mailer]
ENABLED = true
PROTOCOL = smtp+starttls
SMTP_ADDR = mail.example.com
SMTP_PORT = 587
FROM = "Example Code" <git@example.com>
USER = git@example.com
PASSWD = CHANGEME

[actions]
ENABLED = false

[federation]
ENABLED = false

[cron.update_checker]
ENABLED = false

[log]
MODE = console
LEVEL = info
ROUTER = console
EOF

chown root:git /etc/forgejo/app.ini
chmod 640 /etc/forgejo/app.ini

Verify immediately that nothing came out empty:

grep -c '^\[server\]' /etc/forgejo/app.ini    # expect 1
grep -E 'SECRET|TOKEN|PASSWD' /etc/forgejo/app.ini

Every one of those lines should have a value after the equals sign. An empty assignment behaves exactly like a missing key.

INSTALL_LOCK = true skips the web installer entirely. REQUIRE_SIGNIN_VIEW = true and DEFAULT_PRIVATE = private mean nothing is visible without an account, which is what you want for client work. SSL_MODE = disable is correct here because PostgreSQL is on loopback inside the same container.

Note DISABLE_SSH = true. More on that decision below.

The systemd unit

cat > /etc/systemd/system/forgejo.service <<'EOF'
[Unit]
Description=Forgejo
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service

[Service]
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/forgejo
ExecStart=/usr/local/bin/forgejo web --config /etc/forgejo/app.ini
Restart=always
RestartSec=2s
Environment=USER=git HOME=/var/lib/forgejo GITEA_WORK_DIR=/var/lib/forgejo

NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
ReadWritePaths=/var/lib/forgejo
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=true
MemoryDenyWriteExecute=false

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now forgejo
journalctl -u forgejo -n 30 --no-pager

Two details in that unit matter. MemoryDenyWriteExecute stays false because the Go runtime requires it. And ProtectSystem=strict makes /etc read only for this service, which is deliberate. Your configuration file should not be rewritten by the application it configures.

A healthy start ends with lines reporting the listener address and the resolved ROOT_URL.

When it fails: the read only app.ini loop

This is the error that sends people to the forums:

[F] Unable to load settings from config: error saving JWT Secret for custom config:
failed to save "/etc/forgejo/app.ini": open /etc/forgejo/app.ini: read-only file system

The message names the symptom, not the cause. Forgejo is not trying to save your configuration because something is wrong with the file system. It is trying to save a JWT secret that it just generated, because it found the existing one missing, empty or malformed. The read only file system is doing its job.

The cause is almost always one of these:

A placeholder never got substituted, so the value is literally something like PASTE_JWT_SECRET. Forgejo validates JWT secrets as 32 bytes base64url encoded, roughly 43 characters, and rejects anything else.

The variable was empty when the file was written, leaving JWT_SECRET = with nothing after it. This happens when you generate secrets in one shell session and write the configuration in another, or when a quoted heredoc prevents expansion.

LFS_START_SERVER = true is set but LFS_JWT_SECRET is absent. LFS needs its own secret and Forgejo will generate one if you have enabled the server without providing it. This one is easy to miss because the error message says JWT Secret without saying which.

A duplicate [server] section, usually the result of an editing mistake, causing the parser to miss keys you can plainly see in the file.

Diagnose it in one command:

grep -n 'JWT_SECRET\|^\[' /etc/forgejo/app.ini

You should see exactly one [server] header, an LFS_JWT_SECRET with a real value inside it if LFS is enabled, and a different JWT_SECRET under [oauth2].

If you would rather let Forgejo tell you what it wants than guess, allow the write once and inspect the result:

systemctl stop forgejo
cp -a /etc/forgejo/app.ini /root/app.ini.before

mkdir -p /etc/systemd/system/forgejo.service.d
cat > /etc/systemd/system/forgejo.service.d/override.conf <<'EOF'
[Service]
ReadWritePaths=/var/lib/forgejo /etc/forgejo
EOF

chmod 660 /etc/forgejo/app.ini
systemctl daemon-reload
systemctl start forgejo
diff /root/app.ini.before /etc/forgejo/app.ini

The diff is your answer. Then revert the override, restore chmod 640 and chown root:git, reload and restart. If it stays up, the configuration is now self consistent.

One practical note while debugging. Restart=always with a two second interval fills the journal fast and systemctl status will only show you a truncated fragment of the real error. Stop the service and clear the counter before reading:

systemctl stop forgejo
systemctl reset-failed forgejo

Or run it in the foreground as the service user, which gives the cleanest output of all:

sudo -u git /usr/local/bin/forgejo web --config /etc/forgejo/app.ini

Creating the administrator

sudo -u git forgejo admin user create \
  --config /etc/forgejo/app.ini \
  --admin --username gitadmin \
  --email admin@example.com \
  --random-password

The generated password is printed once. Capture it, then change it after your first login.

Since the database password and the secrets passed through your shell history, clear it now that everything is working:

history -c && history -w

Nginx

On the proxy container, assuming a wildcard certificate already exists for the domain:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

upstream forgejo {
    server 10.0.0.20:3000;
    keepalive 16;
}

server {
    listen 80;
    listen [::]:80;
    server_name git.example.com;
    include snippets/acme.conf;
    location / {
        return 301 https://git.example.com$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name git.example.com;

    ssl_certificate /etc/acme/live/*.example.com/fullchain.pem;
    ssl_certificate_key /etc/acme/live/*.example.com/privkey.pem;
    include snippets/ssl.conf;

    access_log /var/log/nginx/git.example.com-access.log;
    error_log /var/log/nginx/git.example.com-error.log;

    add_header Strict-Transport-Security 'max-age=31536000';
    add_header X-Content-Type-Options nosniff;
    add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive";

    client_max_body_size 1024m;

    location / {
        proxy_pass http://forgejo;
        proxy_http_version 1.1;
        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;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_request_buffering off;
        proxy_buffering off;
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
    }
}

Three settings here are not decoration. client_max_body_size 1024m and proxy_request_buffering off are what stand between you and a 413 on the first substantial push, because without them Nginx buffers the entire pack file to disk before forwarding it. And proxy_http_version 1.1 is required for the upstream keepalive to work at all.

If a $connection_upgrade map already exists elsewhere in your configuration, remove it from this file. A duplicate definition will fail the syntax check.

Give the site its own access log. It gives CrowdSec a distinct stream to work with, and failed logins arriving as POSTs to /user/login become actionable.

SSH or not

Forgejo has a built in SSH server and most guides walk you through exposing it. I would push back on that for a small team.

If two or three people are pushing to your forge, HTTPS with a scoped access token does everything SSH does. It works from dynamic addresses, it traverses restrictive corporate networks, it rides on port 443 where your existing web application firewall is already watching and it exposes no additional attack surface. Setting DISABLE_SSH = true also removes the SSH option from the clone dropdown in the interface, so nobody copies a URL that cannot work.

If you do want SSH, my advice in order of usefulness. First, restrict by source address in your firewall, which does far more than anything else on this list. Second, use a high port rather than 2222, not because obscurity is security but because 2222 attracts the same automated noise as 22 and that noise degrades your logs. Keep it below the local port range, which you can check with sysctl net.ipv4.ip_local_port_range, so an inbound DNAT rule cannot collide in conntrack with an outbound source port. Third, if you are forwarding into a container, use a real DNAT rule rather than a userspace relay, otherwise every connection appears to originate from the bridge and your intrusion prevention is blind.

Working with it from the command line

Create a token in the interface under Settings, Applications, Access tokens, scoped to read:repository and write:repository. Forgejo v15 lets you restrict a token to individual repositories, which is worth doing.

git clone https://git.example.com/myorg/myproject.git

The username is your Forgejo account name and the password is the token. Your SSH key plays no part in HTTPS transport, and once you enable two factor authentication your account password will be rejected for git operations, so use a token from the start.

To avoid retyping it, the encrypted option on a desktop:

git config --global credential.helper /usr/libexec/git-core/git-credential-libsecret

Confirm the path on your system with dpkg -L git-credential-libsecret. On a headless server credential.helper store works but writes the token in clear text to ~/.git-credentials, so restrict it to 600 and scope the token narrowly.

Backups

The container itself will be covered by whatever snapshot regime you already run, but a PostgreSQL cluster inside a filesystem snapshot is not guaranteed consistent. Take a proper dump first:

cat > /usr/local/sbin/forgejo-dump.sh <<'EOF'
#!/bin/bash
set -euo pipefail
DEST=/var/backups/forgejo
mkdir -p "$DEST"
cd "$DEST"
sudo -u git /usr/local/bin/forgejo dump \
  --config /etc/forgejo/app.ini \
  --type tar.zst --file "$DEST/forgejo-$(date +%F).tar.zst"
find "$DEST" -name 'forgejo-*.tar.zst' -mtime +14 -delete
EOF
chmod 750 /usr/local/sbin/forgejo-dump.sh

Run it from a systemd timer scheduled just before your container snapshot window, so the backup tooling picks up a consistent artifact alongside the container image. forgejo dump includes the database, the repositories, LFS objects, attachments and the configuration.

Upgrades

Because of the versioned release layout, an upgrade is the download and verify block from earlier with a new version number, followed by:

ln -sfn /opt/forgejo/releases/${FJ_VER} /opt/forgejo/current
systemctl restart forgejo

Rollback is the same command pointing at the previous directory. Run the dump script first regardless, since database migrations are not reversible by a symlink.

Read the release notes before major version jumps. Forgejo documents breaking changes properly and the effort is small compared to restoring from backup.

Frequently asked questions

SQLite is officially supported and works well for a handful of users with Actions disabled. Enable CI and concurrent writes from job status updates and log output start producing lock contention. Since migrating a live instance later means a dump and restore, PostgreSQL is the safer starting point for anything you expect to grow.

Because it generated a secret it considered missing or invalid and tried to write it back into the configuration. Systemd hardening correctly prevented that. Fix the underlying secret rather than making the file writable. The usual culprits are an unsubstituted placeholder, an empty value from a variable that did not expand, or LFS_START_SERVER enabled without LFS_JWT_SECRET.

Yes. Set DISABLE_SSH = true and use HTTPS with access tokens. Clone, fetch and push all work, and for small teams this is usually the better choice because nothing beyond port 443 is exposed.

Nginx is buffering the request and hitting its body size limit. Set client_max_body_size generously and add proxy_request_buffering off in the location block.

Nginx is buffering the request and hitting its body size limit. Set client_max_body_size generously and add proxy_request_buffering off in the location block.

Run forgejo dump on the old instance, build the new container following this guide, restore the repositories directory and the database from the dump, then copy the secrets from the old app.ini. Reusing SECRET_KEY matters, because existing stored credentials are encrypted with it.

Closing thoughts

The whole installation is perhaps thirty minutes of work once you know where the traps are and the result is a forge that runs in about 150MB of RAM, updates by replacing one binary and keeps your source code on hardware you control. For European businesses thinking about where their intellectual property actually lives, that last point is not a small consideration.

The only genuinely fiddly part is the secrets and now you know why.

Similar Posts