Go to file
Andrea Dell'Amico 858cdc4b1b
Do not protect /home.
2026-09-22 16:53:32 +02:00
defaults Do not protect /home. 2026-09-22 16:53:32 +02:00
handlers The systemd unit is now a template. Refactor to satisfy the ansible-lint rules. 2026-08-06 12:15:20 +02:00
meta Fix the parsing of the configuration, add a systemd unit hardening, add tasks that configure LDAP and OIDC. 2026-09-18 18:12:46 +02:00
tasks Fix the parsing of the configuration, add a systemd unit hardening, add tasks that configure LDAP and OIDC. 2026-09-18 18:12:46 +02:00
templates Do not protect /home. 2026-09-22 16:53:32 +02:00
tests The systemd unit is now a template. Refactor to satisfy the ansible-lint rules. 2026-08-06 12:15:20 +02:00
vars Fix the parsing of the configuration, add a systemd unit hardening, add tasks that configure LDAP and OIDC. 2026-09-18 18:12:46 +02:00
.ansible-lint The systemd unit is now a template. Refactor to satisfy the ansible-lint rules. 2026-08-06 12:15:20 +02:00
.gitignore Fix the parsing of the configuration, add a systemd unit hardening, add tasks that configure LDAP and OIDC. 2026-09-18 18:12:46 +02:00
LICENSE Initial commit 2026-08-05 17:03:47 +02:00
README.md Fix the parsing of the configuration, add a systemd unit hardening, add tasks that configure LDAP and OIDC. 2026-09-18 18:12:46 +02:00

README.md

Forgejo

A role that installs forgejo, the self-hosted lightweight software forge. The role downloads the forgejo binary, creates the service user and the directory layout, renders app.ini, installs the systemd unit from a template so that optional dependencies (memcached, redis, a local database) can be declared as Wants=/After= directives, and manages the OAuth2/OIDC and LDAP authentication sources.

Requirements

None. git and git-lfs are installed by the role.

Role Variables

The most important variables are listed below:

forgejo_version: 10.0.0
forgejo_arch: linux-amd64
forgejo_download_url: 'https://codeberg.org/forgejo/forgejo/releases/download/{{ forgejo_tag }}/forgejo-{{ forgejo_version }}-{{ forgejo_arch }}'
# The binary is installed versioned and forgejo_bin_path is a symlink to it, so
# that bumping forgejo_version really installs the new binary and a rollback is
# a symlink flip. The checksum is verified against the published .sha256
forgejo_versioned_bin_path: '/usr/local/lib/forgejo/forgejo-{{ forgejo_version }}-{{ forgejo_arch }}'
forgejo_bin_path: /usr/local/bin/forgejo
forgejo_force_binary_download: false
forgejo_keep_previous_binaries: true

# The service user. forgejo upstream uses the 'git' user
git_create_service_user: true
git_user: git
git_group: '{{ git_user }}'
git_home: /home/git

# FORGEJO_WORK_DIR, the bare repositories, and where app.ini lives
forgejo_work_dir: /var/lib/forgejo
forgejo_repository_data: '{{ forgejo_work_dir }}/repositories'
forgejo_conf_dir: /etc/forgejo

forgejo_enabled: true
forgejo_service_name: forgejo

app.ini

app.ini is rendered from flat variables, one per configuration key. The variable name is the section name with every . and - replaced by _, an underscore, and the key:

app.ini variable
[server] HTTP_PORT server_HTTP_PORT
[cron.archive_cleanup] ENABLED cron_archive_cleanup_ENABLED
[repo-archive] PATH repo_archive_PATH
a key before the first section _default_APP_NAME

A key is written only when its variable is defined and not empty, so app.ini contains exactly what was configured. The set of keys forgejo understands is taken from the app.example.ini of the pinned version: the file is fetched to the controller (forgejo_appconf_example_path) and parsed in memory. Nothing is written inside the role directory, which ansible-galaxy install --force-with-deps would wipe on every run.

forgejo_appconf_example_url: 'https://codeberg.org/forgejo/forgejo/raw/tag/{{ forgejo_tag }}/custom/conf/app.example.ini'
forgejo_appconf_example_path: '{{ lookup("env", "HOME") }}/.ansible/tmp/forgejo-app.example-{{ forgejo_version }}.ini'
# Set it to false to reuse a file already at that path (offline controller)
forgejo_appconf_example_download: true

app.example.ini does not document everything: the named storage sections ([storage.<name>], the documented way to point some subsystems at object storage) and [avatar]/[repo-avatar] are read by the code but absent from the example file. forgejo_ini_extra_sections writes them verbatim, after everything else, and is not checked against the key list:

forgejo_ini_extra_sections:
  storage.s3:
    STORAGE_TYPE: minio
    MINIO_ENDPOINT: 's3.example.org:8080'
    MINIO_BUCKET: forgejo
    MINIO_USE_SSL: 'true'
    MINIO_BUCKET_LOOKUP: path
  avatar:
    STORAGE_TYPE: s3

Authentication sources

OIDC and LDAP sources cannot be declared in app.ini: forgejo keeps them in the login_source table of its own database. The role drives the forgejo admin auth CLI instead, matching each source by name against forgejo admin auth list:

  • not there yet: created with add-oauth / add-ldap;
  • already there: updated with update-oauth --id / update-ldap --id, but only when forgejo_auth_sources_update is true.

Updating is opt-in because the CLI cannot read a source back to compare, so an unconditional update would rewrite the row and report changed on every run. Push a change explicitly:

--tags forgejo_auth -e forgejo_auth_sources_update=true
forgejo_manage_auth_sources: true
forgejo_oauth_sources:
  - name: 'keycloak'          # also the callback path: /user/oauth2/keycloak/callback
    provider: openidConnect
    key: 'forgejo'
    secret: '{{ vault_forgejo_oidc_client_secret }}'
    auto_discover_url: 'https://accounts.example.org/realms/R/.well-known/openid-configuration'
    scopes: 'openid email profile'
    required_claim_name: groups   # login refused unless the claim
    required_claim_value: users   # contains this value
    group_claim_name: groups
    admin_group: admins
forgejo_ldap_sources:
  - name: 'directory'
    security_protocol: ldaps
    host: ldap.example.org
    port: 636
    bind_dn: 'uid=svc,...'
    bind_password: '{{ vault_forgejo_ldap_bind_pwd }}'
    user_search_base: 'cn=users,...'
    user_filter: '(&(memberof=cn=users,...)(uid=%s))'
    admin_filter: '(memberof=cn=admins,...)'
    synchronize_users: true

Renaming a source invalidates the redirect URI registered with the identity provider, because the name is part of the callback path.

An LDAP source used only to synchronize users must still be active: the sync_external_users cron walks the active sources only. To keep the password sign-in form from appearing, set service_ENABLE_INTERNAL_SIGNIN: 'false' rather than deactivating the source. Note that this also removes the only way back in if the OIDC provider is unavailable: recovery means editing app.ini and restarting.

The CLI takes the client secret and the bind password on the command line, so they are visible in the process list of the target while the command runs. The tasks are no_log by default (forgejo_auth_no_log).

Systemd unit tuning:

# Raise it if you have repositories with a lot of files and you get HTTP 500
# errors because of that
forgejo_limit_nofile: ''
# Set it when forgejo listens on a unix socket: systemd creates /run/<dir>
forgejo_runtime_dir: ''
# Set it if git and/or git-lfs are not installed inside the default PATH
forgejo_path_env: ''
# Set it to true to let forgejo bind ports below 1024
forgejo_bind_privileged_ports: false

Systemd sandbox. It is a drop-in, /etc/systemd/system/forgejo.service.d/10-hardening.conf, so the unit stays readable and the sandbox can be taken out of the way by removing one file:

forgejo_systemd_hardening: false
# On top of forgejo_work_dir and forgejo_repository_data, which are always
# listed. Each path must EXIST or the service dies with 226/NAMESPACE
forgejo_systemd_read_write_paths: []
forgejo_systemd_inaccessible_paths: []
forgejo_systemd_protect_home: "{{ 'true' if builtin ssh server else 'false' }}"
forgejo_systemd_private_users: true
# Extra [Service] lines, verbatim
forgejo_systemd_hardening_adjunct: []

Three things about it were established by testing the release binary rather than by reading documentation, and they are the reason the file is not a copy of the usual hardening boilerplate:

  • @resources is denied with :EPERM, not plainly. The go runtime raises RLIMIT_NOFILE to the hard limit at startup, and a plain deny answers that setrlimit with SIGSYS: forgejo core-dumps before printing a line. It survives a plain deny only while LimitNOFILE= happens to set soft equal to hard.
  • ProtectHome= is tied to the builtin SSH server. With it on, forgejo never writes <git_home>/.ssh/authorized_keys. ReadWritePaths= does not punch a hole through ProtectHome=, so the home directory is either hidden or it is not.
  • AF_NETLINK is not in RestrictAddressFamilies=. It is not needed while /etc/nsswitch.conf says hosts: files dns, which keeps name resolution inside the go resolver; a host joined to sssd or nss-resolve sends go through glibc’s getaddrinfo and needs it.

PrivateTmp=true gives the service a private /var/tmp as well, so a ReadWritePaths= under it does not exist inside the namespace.

Service dependencies. Every service enabled below is added to the systemd unit as a Wants=/After= pair:

forgejo_local_postgresql: false
forgejo_local_mysql: false
forgejo_local_mariadb: false
forgejo_local_memcache: false
forgejo_local_redis: false
# Any other unit, by name
forgejo_service_dependencies_adjunct: []

The unit names can be overridden when they differ from the distribution defaults: forgejo_redis_service_name, forgejo_memcached_service_name, forgejo_postgresql_service_name, forgejo_mysql_service_name, forgejo_mariadb_service_name.

Tags

  • forgejo: everything
  • forgejo_binary: the binary download and the symlink only
  • forgejo_service: the systemd unit and the service state only
  • forgejo_prepare: fetch the app.example.ini of the pinned version and build the map of the keys forgejo understands
  • forgejo_conf: render app.ini (needs forgejo_prepare, which is why both tags run the prepare tasks)
  • forgejo_auth: the OAuth2/OIDC and LDAP authentication sources only

Dependencies

The memcached and redis roles, installed only when forgejo_local_memcache and/or forgejo_local_redis are true.

Example Playbook

- hosts: forgejo-servers
  roles:
    - role: forgejo
      forgejo_version: 10.0.0
      forgejo_local_postgresql: true
      forgejo_local_redis: true
      forgejo_local_memcache: true

License

EUPL-1.2

Author Information

Andrea Dell’Amico, andrea.dellamico@isti.cnr.it Franca Debole, franca.debole@isti.cnr.it