Build and supply chain

Self-hosting Forgejo, the secure way

Self-hosting your Git forge means the source code, the CI secrets and the deploy keys all live on a box you now own. Forgejo ships with sensible defaults for a community site. Your forge is not a community site.

The short answer

Run Forgejo rootless, read-only, with no capabilities. In app.ini, lock the installer, disable registration, require sign-in to view anything, require 2FA for every account, keep git hooks disabled, block migrations and webhooks to private addresses, set every secret explicitly, and back up SECRET_KEY offline.

Updated Houssam Hammoudi, CTOTested with Forgejo 16.0.5 (rootless image), curl 8.16

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

A forge holds more than code. It holds CI secrets, deploy keys, webhook tokens and, through Actions, the ability to run code on your runners. Several Forgejo defaults are right for a public community instance and wrong for a private one:

  • Registration is open, so anyone who finds the URL can create an account.
  • Public repositories and the API are visible without login.
  • Two-factor authentication is optional.
  • Migrations and webhooks can be pointed at any address, which turns the forge into a way to reach internal services (server-side request forgery).
  • Secrets such as SECRET_KEY are generated on first start and written into app.ini, so the config file silently becomes a secret, and losing it means losing data.

What the docs say

Custom Git Hooks can be used to perform arbitrary code execution on the host operating system.

Source: Forgejo docs, Configuration cheat sheet (DISABLE_GIT_HOOKS)

ALLOW_LOCALNETWORKS: false: Allow private addresses defined by RFC 1918, RFC 1122, RFC 4632, RFC 4291, and RFC 6598.

Source: Forgejo docs, Configuration cheat sheet (migrations)

REQUIRE_SIGNIN_VIEW: false: Enable this to force users to log in to view any page or to use the API.

Source: Forgejo docs, Configuration cheat sheet (service)

This key is VERY IMPORTANT; if you lose it, data encrypted by it (like 2FA secrets) can no longer be decrypted.

Source: Forgejo docs, Configuration cheat sheet (SECRET_KEY)

The cheat sheet lists each setting with its default, but it is a reference, not a hardening guide. The safe values for a private forge are spread across a dozen sections, and some defaults (open registration, no sign-in required, optional 2FA) are the opposite of what a company forge needs.

The secure configuration

app.ini, with the secrets filled in from forgejo generate secret <NAME> and stored in your secret manager:

ini
APP_NAME = Forgejo
RUN_MODE = prod

[server]
DOMAIN           = git.example.com
ROOT_URL         = https://git.example.com/
HTTP_ADDR        = 0.0.0.0
HTTP_PORT        = 3000
PROTOCOL         = http               ; TLS ends at the reverse proxy in front
DISABLE_SSH      = false
START_SSH_SERVER = true               ; built-in SSH server, no system sshd
SSH_LISTEN_PORT  = 2222
LFS_START_SERVER = true
LFS_JWT_SECRET   = __LFS_JWT_SECRET__
OFFLINE_MODE     = true               ; no avatars or assets fetched from third parties

[database]
DB_TYPE = sqlite3                     ; test only; PostgreSQL with TLS in production
PATH    = /var/lib/gitea/data/forgejo.db

[security]
INSTALL_LOCK                  = true  ; the web installer is never reachable
SECRET_KEY                    = __SECRET_KEY__
INTERNAL_TOKEN                = __INTERNAL_TOKEN__
DISABLE_GIT_HOOKS             = true  ; server-side hooks would be code execution on the host
MIN_PASSWORD_LENGTH           = 12
PASSWORD_CHECK_PWN            = false ; true sends hash prefixes to an outside service; decide explicitly
GLOBAL_TWO_FACTOR_REQUIREMENT = all   ; every account needs 2FA
LOGIN_REMEMBER_DAYS           = 7

[service]
DISABLE_REGISTRATION         = true   ; accounts come from SSO or an admin
REQUIRE_SIGNIN_VIEW          = true   ; nothing is visible without login
DEFAULT_KEEP_EMAIL_PRIVATE   = true
DEFAULT_ALLOW_CREATE_ORGANIZATION = false
ENABLE_NOTIFY_MAIL           = false

[repository]
DEFAULT_PRIVATE = private             ; new repositories start private

[migrations]
ALLOW_LOCALNETWORKS = false           ; no migrations or mirrors from private addresses (SSRF)
ALLOWED_DOMAINS     = github.com,codeberg.org

[webhook]
ALLOWED_HOST_LIST = external          ; webhooks only to public addresses
SKIP_TLS_VERIFY   = false

[actions]
ENABLED             = true
DEFAULT_ACTIONS_URL = https://git.example.com   ; short "uses:" names resolve here

[oauth2]
ENABLED    = false                    ; Forgejo is not an identity provider here; login comes from your SSO
JWT_SECRET = __JWT_SECRET__           ; still required: other modules sign with it

[session]
COOKIE_SECURE = true
SAME_SITE     = strict

[cors]
ENABLED = false

[api]
ENABLE_SWAGGER = false

[log]
LEVEL = Warn

[cron.update_checker]
ENABLED = false

Set JWT_SECRET even with the OAuth2 provider off: without it, Forgejo generates one on start and tries to write it into app.ini, which fails on a read-only config (the test hit exactly that). If webhooks must reach internal services, list those hosts explicitly in ALLOWED_HOST_LIST instead of switching to *.

The container:

bash
docker run -d --name forgejo \
  --read-only --tmpfs /tmp/gitea:uid=1000,gid=1000 \
  --cap-drop ALL --security-opt no-new-privileges \
  --memory 1g \
  -v forgejo-data:/var/lib/gitea \
  -v /etc/forgejo/app.ini:/etc/gitea/app.ini:ro \
  -e GITEA_APP_INI=/etc/gitea/app.ini \
  -p 127.0.0.1:3000:3000 -p 2222:2222 \
  codeberg.org/forgejo/forgejo@sha256:<digest>      # 16.0.5-rootless, pinned

Put a TLS reverse proxy in front of port 3000, and make the site administration pages reachable only from your private network or tailnet.

Prove it

Forgejo 16.0.5 started with this app.ini, probed without credentials:

bash
curl -s http://forgejo:3000/api/healthz
curl -s -o /dev/null -w "%{http_code} %{redirect_url}" http://forgejo:3000/explore/repos
curl -s http://forgejo:3000/api/v1/repos/search
curl -s http://forgejo:3000/api/v1/version
text
pass {'cache:ping': 'pass', 'database:ping': 'pass'}
303 http://forgejo:3000/user/login
{"message":"Only signed in user is allowed to call APIs."}
{"message":"Only signed in user is allowed to call APIs."}

(The health line is summarized from the JSON.) Registration, the installer and the API explorer:

bash
curl -s http://forgejo:3000/user/sign_up | grep -o "Registration is disabled[^<]*"
curl -s -o /dev/null -w "%{http_code}" http://forgejo:3000/install
curl -s -o /dev/null -w "%{http_code}" http://forgejo:3000/swagger
text
Registration is disabled. Please contact your site administrator.
303
303

Both redirect to the login page. An admin account created on the command line, with a correct password but no second factor enrolled yet:

bash
curl -s -u site-admin:... http://forgejo:3000/api/v1/user
text
{"message":"This Forgejo instance requires users to enable two-factor authentication before they can access their accounts. Enable it at: http://forgejo:3000/user/settings/security"}

A stolen password alone does not open the API. The process itself:

bash
docker exec forgejo id ; docker exec forgejo grep CapEff /proc/1/status
text
uid=1000(git) gid=1000(git) groups=1000(git)
CapEff:	0000000000000000

Mistakes people make

Letting Forgejo write its own secrets

On first start, Forgejo generates missing secrets and writes them into app.ini. If that file is not backed up, a restore loses SECRET_KEY, and with it every 2FA secret and anything else Forgejo encrypted with that key. Generate the secrets yourself, store them in a secret manager, and mount the config read-only.

Allowing local networks for migrations

ALLOW_LOCALNETWORKS = true lets any user who can create a migration or a mirror make the forge fetch from internal addresses, including cloud metadata. Keep it false and allowlist the domains you mirror from.

Webhooks to "*"

ALLOWED_HOST_LIST = * turns every repository admin into someone who can send requests from the forge to any internal service. Use external plus a short list of named internal hosts.

Enabling git hooks for one team

Git hooks run on the server as the Forgejo user. Anyone with the hook permission can read app.ini, the database and every repository. Use Actions on isolated runners for automation instead.

The forge on the public internet with admin pages

With SSO and 2FA the login is strong, but the admin area, the API and SSH are still attack surface. Expose only what users need, and put the rest behind a private network.

Checklist

  • INSTALL_LOCK = true, and every secret is set explicitly and stored outside the server.
  • DISABLE_REGISTRATION = true and accounts come from SSO or an admin.
  • REQUIRE_SIGNIN_VIEW = true.
  • GLOBAL_TWO_FACTOR_REQUIREMENT = all.
  • DISABLE_GIT_HOOKS = true.
  • ALLOW_LOCALNETWORKS = false with an ALLOWED_DOMAINS list for migrations.
  • ALLOWED_HOST_LIST = external (plus named hosts only if needed) for webhooks.
  • New repositories default to private.
  • The container runs rootless, read-only, with no capabilities, from a pinned digest.
  • Admin pages are reachable only from a private network.
  • app.ini and SECRET_KEY are in the backup, and restores are tested.

A forge is the front door to everything you build. Lock it like one, not like a community notice board.

H2-CSDE

Learn it on a live range

Self-hosting the forge, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.

Start free

H2 Scanner

Want this caught before it merges?

The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.

Talk to us