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.
On this page
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_KEYare generated on first start and written intoapp.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:
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 = falseSet 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:
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, pinnedPut 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:
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/versionpass {'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:
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/swaggerRegistration is disabled. Please contact your site administrator.
303
303Both redirect to the login page. An admin account created on the command line, with a correct password but no second factor enrolled yet:
curl -s -u site-admin:... http://forgejo:3000/api/v1/user{"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:
docker exec forgejo id ; docker exec forgejo grep CapEff /proc/1/statusuid=1000(git) gid=1000(git) groups=1000(git)
CapEff: 0000000000000000Mistakes 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 = trueand accounts come from SSO or an admin.REQUIRE_SIGNIN_VIEW = true.GLOBAL_TWO_FACTOR_REQUIREMENT = all.DISABLE_GIT_HOOKS = true.ALLOW_LOCALNETWORKS = falsewith anALLOWED_DOMAINSlist 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.iniandSECRET_KEYare 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 freeH2 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