Skip to content

forgejo-git-server

Modules

A NixOS module for self-hosting Forgejo behind an nginx TLS front-end. It supports two deployment shapes behind one option set:

  • Container (asContainer = true, default): a podman container built from a locally-assembled OCI image, running as a non-root uid inside, optionally on a sandboxed runtime such as gVisor/runsc. (The podman unit itself is an ordinary root-run system service — virtualisation.oci-containers has no rootless mode.)
  • Native (asContainer = false): NixOS's built-in services.forgejo, typically over a unix socket.

Both share the same nginx vhost, database wiring, and optional OIDC login-source bootstrap.

The image is assembled from your nixpkgs' pkgs.forgejo, so the version you get is whatever that pin ships (15.x or newer).

Most of this module is not glue — it is a set of hard-won workarounds for the stricter startup checks Forgejo introduced in 15.x, and for git smart-HTTP over a reverse proxy. The traps are the point.

The traps this encodes

1. RUN_USER must match the in-container passwd name, or startup dies

Since 15.x, Forgejo's "current run user matches config" check is fatal. In a container the process runs as a numeric uid that has no name unless you give it one. The module ships a synthetic /etc/passwd (containerEtc) that names the container uid root, and hardcodes RUN_USER = root to match. Change one without the other and Forgejo refuses to boot. If you re-point the container to a different uid, the passwd entry regenerates automatically — but the name stays root on purpose.

2. Externally-added SSH keys abort boot unless you allow them

Since 15.x, Forgejo hard-fails at startup if .ssh/authorized_keys contains any key not present in its database (modules/ssh/init.go). If anything ever added a key out-of-band — a CI runner, a mirror/backup daemon that pulls over git-over-ssh — the new check flags it as "unexpected" and aborts. The upstream suggestion to just delete the file would drop those keys and can lock out legitimate fetchers. This module sets SSH_ALLOW_UNEXPECTED_AUTHORIZED_KEYS = true instead: the keys still authenticate, only the startup consistency assertion is disabled.

3. proxy_buffering off is load-bearing for remote git clones

With nginx's default proxy_buffering on, nginx buffers the entire git upload-pack response and truncates the tail when flushing to a client with non-zero latency. The clone or fetch dies with:

fatal: early EOF
fatal: unexpected disconnect while reading sideband packet

The insidious part: loopback clients are unaffected, so it works perfectly on the box and only fails for real remote clients over the network. The module sets proxy_buffering off and proxy_request_buffering off on the git vhost. Do not "clean this up."

4. The DB password never enters the Nix store

app.ini ships with a literal `FORGEJO_DB_PASSWD` placeholder. At deploy time the container's preStart reads the password from database.passwordFile and sed-substitutes it into a rendered app.ini inside the data volume, then chmod 600s it. The Nix-store copy only ever contains the placeholder, so the secret is never world-readable in /nix/store and never in your git history.

5. uid/gid pinning for persistent repo storage

If repositories live on persistent storage whose ownership must survive rebuilds, pin uid/gid. A silent uid drift will leave Forgejo unable to read its own repositories.

Usage

Import the module and enable it. Minimal SQLite example:

{
  imports = [ ./forgejo-git-server ];

  modules.forgejo = {
    enable = true;
    domain = "git.example.com";
    acmeHost = "git.example.com";   # references security.acme.certs.<name>
    database.type = "sqlite3";
  };
}

Postgres, container shape, with a secret file:

modules.forgejo = {
  enable = true;
  asContainer = true;
  domain = "git.example.com";
  acmeHost = "git.example.com";
  uid = 990;
  gid = 990;

  database = {
    type = "postgres";
    host = "127.0.0.1";
    name = "forgejo";
    user = "forgejo";
    passwordFile = "/run/secrets/forgejo-dbpassword";
    configureLocalAuth = true;   # add local peer/ident rules to services.postgresql
  };

  # Optional: stronger container isolation. You must install/register the
  # runtime on the host yourself.
  containerRuntime = "runsc";   # gVisor
};

Advertise SSH clone URLs while the host's openssh actually serves them (a common setup — a git/gitea user with forced-command authorized_keys):

modules.forgejo = {
  # ...
  sshPort = 22;      # port shown in clone URLs
  sshUser = "git";   # user shown in clone URLs
  sshBuiltin = false; # do NOT run Forgejo's built-in sshd; host openssh serves it
};

To instead run Forgejo's bundled SSH server inside the container, set sshBuiltin = true (the firewall port is opened automatically).

OIDC

Setting oidc.enable = true installs a oneshot unit that waits for both Forgejo and the OIDC discovery endpoint to come up, creates the OAuth login source via forgejo admin auth add-oauth (idempotent), and flips is_sync_enabled in the login_source table to enable auto-registration.

modules.forgejo.oidc = {
  enable = true;
  discoveryUrl = "https://idp.example.com/realms/main/.well-known/openid-configuration";
  clientId = "forgejo";
  clientSecretFile = "/run/secrets/forgejo-oidc-secret";
  groupClaimName = "groups";
  adminGroup = "forgejo-admins";
};

Note: the is_sync_enabled flip is issued via psql against a Postgres backend; it is a no-op / harmless failure on other engines.

Secret handling. The OIDC client secret is read from clientSecretFile at runtime and is never written to the systemd journal — the setup unit does not echo it and does not dump the login_source.cfg column (which stores the secret) to stdout. It is, however, passed to forgejo admin auth add-oauth via --secret, because Forgejo's CLI accepts the client secret only as a command-line argument (there is no env-var, stdin, or --secret-file input upstream). It is therefore briefly present in /proc/<pid>/cmdline for the lifetime of that one exec, running as the unprivileged forgejo user. On a multi-tenant host where other local users must not observe it, mount /proc with hidepid=2.

Key options

Option Default Purpose
enable false Turn the module on.
appName Forgejo: Beyond Coding. We Forge. APP_NAME shown in the UI.
domain — (required) Public domain; drives ROOT_URL and the nginx vhost.
acmeHost null ACME cert name for TLS; null leaves TLS unconfigured.
asContainer true Podman-container shape vs. native services.forgejo.
httpPort 3000 Internal HTTP port behind nginx.
dataDir /var/lib/forgejo State directory (/data inside the container).
uid / gid null Pin service uid/gid for stable on-disk ownership.
containerRuntime null podman --runtime (e.g. runsc for gVisor).
containerExtraOptions [ "--network=host" ] Extra podman run flags.
useUnixSocket true Native shape: unix socket vs. TCP.
unixSocket /run/forgejo/forgejo.sock Socket path for the native unix-socket shape.
sshPort / sshUser / sshBuiltin null / git / false SSH clone-URL display vs. actually serving SSH.
theme.default / theme.list forgejo-auto / forgejo-auto,forgejo-light,forgejo-dark UI theme defaults (container shape's app.ini).
database.* sqlite3 Engine, connection, passwordFile, sslMode, socket, path, createDatabase, configureLocalAuth.
oidc.* disabled Auto-configure an OpenID Connect login source (discoveryUrl, clientId, clientSecretFile, scopes, authSourceName, groupClaimName, adminGroup, restrictedGroup).

Caveats

  • The container shape uses --network=host by default so nginx's loopback proxyPass reaches the container. The container binds its plaintext HTTP listener to 127.0.0.1, so the unauthenticated, TLS-less port is not reachable off-box even if you loosen the firewall — all outside traffic must go through the nginx TLS front-end. If you switch containerExtraOptions to an isolated container network you must change the app.ini HTTP_ADDR back to 0.0.0.0 and repoint the proxyPass accordingly.
  • SSL_MODE defaults to disable, which assumes the database is local (loopback or unix socket). If you set database.host to a remote DB, also set database.sslMode (e.g. "require") so the DB password and repo metadata are not sent in cleartext. This applies to the container shape; the native shape inherits the upstream services.forgejo default.
  • The module appends to the host's sshd_config unconditionally. Enabling it adds a Match User forgejo block setting PubkeyAuthOptions none, so authorized_keys forced-commands behave predictably when git-over-ssh is routed through the host's openssh. That happens in both shapes, whether or not you use SSH cloning.
  • configureLocalAuth writes to the host's upstream services.postgresql authentication/identMap. If you already manage those elsewhere, leave it false to avoid conflicts.
  • Sandboxed runtimes (runsc) must be installed and registered with podman on the host; this module only passes the --runtime flag.
  • The option namespace is modules.forgejo (not services.forgejo), chosen to avoid colliding with the upstream NixOS module it can delegate to.

Source

modules/forgejo-git-server/default.nix
# forgejo-git-server — self-hosting Forgejo 15.x behind nginx on NixOS.
#
# Supports two deployment shapes behind one option set:
#   - asContainer = true  : a rootless podman container built from a
#                           locally-assembled OCI image (optionally on a
#                           sandboxed runtime like gVisor/runsc).
#   - asContainer = false : NixOS's native services.forgejo, typically over
#                           a unix socket.
#
# Both shapes share the same nginx TLS front-end, database wiring and optional
# OIDC. Most of the file's bulk is workarounds for Forgejo 15.x's stricter
# startup checks — read the inline comments before touching RUN_USER, the
# authorized_keys handling, the app.ini password templating, or proxy_buffering.
{
  config,
  lib,
  pkgs,
  ...
}:
with lib;
let
  cfg = config.modules.forgejo;
  forgejoDbCfg = removeAttrs cfg.database [
    "password"
    "configureLocalAuth"
    "sslMode"
  ];

  containerUid = toString (if cfg.uid != null then cfg.uid else 1000);
  containerGid = toString (if cfg.gid != null then cfg.gid else 1000);

  # Synthetic /etc/passwd for the container. This is load-bearing: it names the
  # container uid "root" so that Forgejo's run-user check resolves the running
  # user to the name we hardcode as RUN_USER below. See the RUN_USER comment.
  containerEtc = pkgs.runCommand "forgejo-etc" { } ''
    mkdir -p $out/etc $out/usr/bin
    echo 'root:x:${containerUid}:${containerGid}:root:/data:/bin/bash' > $out/etc/passwd
    echo 'root:x:${containerGid}:' > $out/etc/group
    ln -s ${pkgs.coreutils}/bin/env $out/usr/bin/env
  '';

  forgejoImage = pkgs.dockerTools.buildLayeredImage {
    name = "forgejo";
    tag = "latest";
    contents = with pkgs; [
      forgejo
      git
      git-lfs
      bash
      coreutils
      gnugrep
      cacert
      containerEtc
    ];
    config = {
      Cmd = [
        "${pkgs.forgejo}/bin/forgejo"
        "--config"
        "/data/custom/conf/app.ini"
      ];
      WorkingDir = "/data";
      Env = [
        "HOME=/data"
        "FORGEJO_WORK_DIR=/data"
        "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
      ];
    };
  };

  # The DB password never enters the Nix store: app.ini ships with a literal
  # `FORGEJO_DB_PASSWD` placeholder that preStart sed-substitutes from the
  # runtime passwordFile at deploy time (see podman-forgejo.preStart below).
  appIni = pkgs.writeText "forgejo-app.ini" ''
    APP_NAME = ${cfg.appName}
    # Stays "root": containerEtc names the container uid (cfg.uid) "root" in
    # /etc/passwd, so Forgejo's run-user check (mustCurrentRunUserMatch)
    # resolves the running user to "root". Forgejo 15.x makes that check fatal —
    # RUN_USER must equal the name the in-container passwd gives cfg.uid, which
    # is "root" here. Change one without the other and startup hard-fails.
    RUN_USER = root
    RUN_MODE = prod
    WORK_PATH = /data

    [database]
    DB_TYPE = ${cfg.database.type}
    HOST = ${cfg.database.host}:${toString cfg.database.port}
    NAME = ${cfg.database.name}
    USER = ${cfg.database.user}
    PASSWD = `FORGEJO_DB_PASSWD`
    SSL_MODE = ${cfg.database.sslMode}
    MAX_OPEN_CONNS = 50
    MAX_IDLE_CONNS = 10
    CONN_MAX_LIFETIME = 5m

    [security]
    INSTALL_LOCK = true

    [server]
    # Forgejo 15.x hard-fails at startup if /data/.ssh/authorized_keys contains
    # any key not present in the DB (modules/ssh/init.go). If you have keys that
    # were added out-of-band (e.g. a CI runner or a mirror daemon pulling over
    # git-over-ssh), the new check flags them as "unexpected" and aborts. The
    # upstream "just delete the file" fix would drop those keys and can lock out
    # legitimate fetchers. Permitting them keeps the keys authenticating
    # normally; this only disables the startup consistency assertion.
    SSH_ALLOW_UNEXPECTED_AUTHORIZED_KEYS = true
    LFS_START_SERVER = true
    PROTOCOL = http
    # Bind the plaintext listener to loopback only. With the default
    # --network=host the container shares the host netns, so nginx reaches it
    # at 127.0.0.1:${toString cfg.httpPort} and nothing off-box should ever
    # touch this unauthenticated, TLS-less port. If you switch to an isolated
    # container network you must set this to 0.0.0.0 (and repoint the nginx
    # proxyPass) — see containerExtraOptions.
    HTTP_ADDR = 127.0.0.1
    HTTP_PORT = ${toString cfg.httpPort}
    DOMAIN = ${cfg.domain}
    ROOT_URL = https://${cfg.domain}
    LOCAL_ROOT_URL = http://127.0.0.1:${toString cfg.httpPort}/
    ${lib.optionalString (cfg.sshPort != null) ''
      DISABLE_SSH = false
      BUILTIN_SSH_SERVER_USER = ${cfg.sshUser}
      SSH_USER = ${cfg.sshUser}
      SSH_DOMAIN = ${cfg.domain}
      SSH_PORT = ${toString cfg.sshPort}
    ''}
    ${lib.optionalString (cfg.sshPort != null && cfg.sshBuiltin) ''
      START_SSH_SERVER = true
      SSH_LISTEN_HOST = 0.0.0.0
      SSH_LISTEN_PORT = ${toString cfg.sshPort}
    ''}

    [lfs]
    PATH = /data/lfs

    [repository]
    ROOT = /data/repositories
    ENABLE_PUSH_CREATE_USER = true
    ENABLE_PUSH_CREATE_ORG = true

    [git.timeout]
    MIGRATE = 3600
    MIRROR = 3600
    CLONE = 1200
    PULL = 1200
    GC = 120

    [log]
    ROOT_PATH = /data/log

    [log.console.router]
    LEVEL = Warn

    [ui]
    DEFAULT_THEME = ${cfg.theme.default}
    THEMES = ${cfg.theme.list}
  '';
in
{
  options.modules.forgejo = {
    enable = mkEnableOption "Forgejo git server setup";

    appName = mkOption {
      type = types.str;
      default = "Forgejo: Beyond Coding. We Forge.";
      description = "APP_NAME shown in the Forgejo UI.";
    };

    uid = mkOption {
      type = types.nullOr types.int;
      default = null;
      description = ''
        User ID for the Forgejo service (null = let the system assign). Pin
        this when repositories live on shared/persistent storage whose
        on-disk ownership must stay stable across rebuilds — a uid drift will
        make Forgejo unable to read its own repos.
      '';
    };

    gid = mkOption {
      type = types.nullOr types.int;
      default = null;
      description = "Group ID for the Forgejo service (null = let the system assign).";
    };

    dataDir = mkOption {
      type = types.str;
      description = "Directory to store data.";
      default = "/var/lib/forgejo";
    };

    domain = mkOption {
      type = types.str;
      example = "git.example.com";
      description = "Domain name for the Forgejo instance.";
    };

    httpPort = mkOption {
      type = types.int;
      default = 3000;
      description = "HTTP port for the Forgejo service (behind the nginx front-end).";
    };

    sshPort = mkOption {
      type = types.nullOr types.port;
      default = null;
      description = ''
        Port that appears in clone URLs for git over SSH. Independent of where
        the SSH endpoint actually runs; see `sshBuiltin` for Forgejo's bundled
        server vs. an external one (typically the host's openssh routing the
        git user via authorized_keys forced commands). Setting this to null
        suppresses SSH URL advertisement entirely (HTTPS clone only).
      '';
    };

    sshUser = mkOption {
      type = types.str;
      default = "git";
      description = ''
        Username that appears in SSH clone URLs (the `<user>@host` portion).
        The actual server-side user is determined by whatever is serving SSH
        (Forgejo's built-in if `sshBuiltin = true`, or the host's openssh
        otherwise). On hosts that route through openssh with
        `Match User git,gitea`, either value here works.
      '';
    };

    sshBuiltin = mkOption {
      type = types.bool;
      default = false;
      description = ''
        Whether to run Forgejo's built-in SSH server inside the container.
        When false (default) only URL-display settings
        (SSH_USER/SSH_PORT/SSH_DOMAIN) are emitted and something else —
        typically the host's openssh — is assumed to handle the actual SSH
        endpoint. Requires `sshPort` to be non-null.
      '';
    };

    acmeHost = mkOption {
      type = types.nullOr types.str;
      default = null;
      description = ''
        ACME certificate name to use for the nginx vhost (see
        `security.acme.certs`). If null, TLS is not configured here.
      '';
    };

    containerRuntime = mkOption {
      type = types.nullOr types.str;
      default = null;
      example = "runsc";
      description = ''
        OCI runtime to pass to podman as `--runtime` for the container shape.
        null uses podman's default (crun/runc). Set to a sandboxed runtime
        such as "runsc" (gVisor) for stronger isolation — you are responsible
        for installing and registering that runtime on the host.
      '';
    };

    containerExtraOptions = mkOption {
      type = types.listOf types.str;
      default = [ "--network=host" ];
      description = ''
        Extra options passed to podman run for the container shape. The
        default uses host networking so the loopback proxyPass from nginx
        reaches the container directly.
      '';
    };

    useUnixSocket = mkOption {
      type = types.bool;
      default = true;
      description = ''
        Whether the native (asContainer = false) shape listens on a unix
        socket instead of TCP. Ignored for the container shape.
      '';
    };

    unixSocket = mkOption {
      type = types.str;
      default = "/run/forgejo/forgejo.sock";
      description = "Path to the unix socket file.";
    };

    database = {
      type = mkOption {
        type = types.enum [
          "sqlite3"
          "mysql"
          "postgres"
        ];
        example = "postgres";
        default = "sqlite3";
        description = "Database engine to use.";
      };

      host = mkOption {
        type = types.str;
        default = "127.0.0.1";
        description = "Database host address.";
      };

      sslMode = mkOption {
        type = types.str;
        default = "disable";
        example = "require";
        description = ''
          SSL_MODE for the container-shape database connection (postgres/mysql).
          Defaults to "disable", which is correct for the documented default of
          a loopback/socket-local database. If you point `database.host` at a
          remote host, set this to "require"/"verify-ca"/"verify-full" so the
          DB credentials and repository metadata are not sent in cleartext.
          Ignored for the sqlite3 engine and for the native (asContainer =
          false) shape, which uses the upstream module's own default.
        '';
      };

      port = mkOption {
        type = types.port;
        default =
          if cfg.database.type == "postgres" then
            5432
          else if cfg.database.type == "mysql" then
            3306
          else
            0;
        defaultText = literalExpression ''if type == "postgres" then 5432 else 3306'';
        description = "Database host port.";
      };

      name = mkOption {
        type = types.str;
        default = "forgejo";
        description = "Database name.";
      };

      user = mkOption {
        type = types.str;
        default = "forgejo";
        description = "Database user.";
      };

      password = mkOption {
        type = types.str;
        default = "";
        description = ''
          The password corresponding to {option}`database.user`.
          Warning: this is stored in cleartext in the Nix store!
          Use {option}`database.passwordFile` instead.
        '';
      };

      passwordFile = mkOption {
        type = types.nullOr types.path;
        default = null;
        example = "/run/secrets/forgejo-dbpassword";
        description = ''
          A file containing the password corresponding to
          {option}`database.user`. Read at deploy time and substituted into
          app.ini, so the password never enters the Nix store.
        '';
      };

      socket = mkOption {
        type = types.nullOr types.path;
        default = "/run/postgresql";
        description = "Path to the unix socket file to use for authentication.";
      };

      path = mkOption {
        type = types.str;
        default = "${cfg.dataDir}/data/forgejo.db";
        defaultText = literalExpression ''"''${dataDir}/data/forgejo.db"'';
        description = "Path to the sqlite3 database file.";
      };

      createDatabase = mkOption {
        type = types.bool;
        default = true;
        description = "Whether to create a local database automatically (native shape).";
      };

      configureLocalAuth = mkOption {
        type = types.bool;
        default = false;
        description = ''
          When true and type == "postgres", add local peer/ident
          authentication rules and an ident map to the host's
          `services.postgresql` so the forgejo system user can connect over
          the local socket without a password. Leave false if you manage
          Postgres authentication elsewhere.
        '';
      };
    };

    theme = {
      default = mkOption {
        type = types.str;
        default = "forgejo-auto";
        description = "Default theme name for the Forgejo UI.";
      };
      list = mkOption {
        type = types.str;
        default = "forgejo-auto,forgejo-light,forgejo-dark";
        description = "Comma-separated list of available themes.";
      };
    };

    asContainer = mkOption {
      type = types.bool;
      default = true;
      description = "Whether to run Forgejo in a podman container or via native services.forgejo.";
    };

    oidc = {
      enable = mkOption {
        type = types.bool;
        default = false;
        description = "Enable automatic OIDC authentication-source configuration.";
      };

      clientSecretFile = mkOption {
        type = types.nullOr types.path;
        default = null;
        description = "Path to file containing the OIDC client secret.";
      };

      discoveryUrl = mkOption {
        type = types.str;
        default = "";
        example = "https://idp.example.com/realms/main/.well-known/openid-configuration";
        description = "OIDC auto-discovery URL.";
      };

      clientId = mkOption {
        type = types.str;
        default = "forgejo";
        description = "OIDC client ID.";
      };

      scopes = mkOption {
        type = types.str;
        default = "openid profile email";
        description = "OIDC scopes.";
      };

      authSourceName = mkOption {
        type = types.str;
        default = "oidc";
        description = "Name for the OIDC authentication source.";
      };

      groupClaimName = mkOption {
        type = types.str;
        default = "";
        example = "groups";
        description = "Name of the claim in the OIDC token that contains group memberships.";
      };

      adminGroup = mkOption {
        type = types.str;
        default = "";
        description = "Group name that grants admin privileges in Forgejo.";
      };

      restrictedGroup = mkOption {
        type = types.str;
        default = "";
        description = "Group name that marks users as restricted in Forgejo.";
      };
    };
  };

  config = mkIf cfg.enable (mkMerge [
    {
      assertions = [
        {
          assertion = cfg.database.type != "sqlite3" || cfg.database.path != null;
          message = "Database path must be set when using SQLite";
        }
        {
          assertion =
            (cfg.database.type == "sqlite3")
            || (cfg.database.passwordFile != null || cfg.database.password != "");
          message = "Either database password or passwordFile must be set for MySQL/PostgreSQL";
        }
        {
          assertion = (cfg.database.type == "sqlite3") || cfg.database.host != "";
          message = "Database host must be set for MySQL/PostgreSQL";
        }
        {
          assertion = (cfg.database.type == "sqlite3") || cfg.database.name != "";
          message = "Database name must be set for MySQL/PostgreSQL";
        }
        {
          assertion = (cfg.database.type == "sqlite3") || cfg.database.user != "";
          message = "Database user must be set for MySQL/PostgreSQL";
        }
      ];

      # Force pubkey auth options off for the forgejo user so that
      # authorized_keys forced-commands behave predictably when git-over-ssh is
      # routed through the host's openssh.
      services.openssh.extraConfig = ''
        Match User forgejo
          PubkeyAuthOptions none
      '';

      services.nginx = {
        enable = true;
        virtualHosts."${cfg.domain}" = mkMerge [
          {
            locations."/" = {
              proxyPass =
                if cfg.asContainer then
                  "http://127.0.0.1:${toString cfg.httpPort}"
                else if cfg.useUnixSocket then
                  "http://unix:${cfg.unixSocket}"
                else
                  "http://localhost:${toString cfg.httpPort}";
              proxyWebsockets = true;
              extraConfig = ''
                client_max_body_size 512M;
                proxy_read_timeout        1h;
                chunked_transfer_encoding on;
                # Stream git smart-HTTP responses instead of buffering them.
                # With proxy_buffering on (the default), nginx buffers the whole
                # upload-pack response and truncates the tail when flushing to a
                # remote (latency > 0) client — git aborts with "early EOF /
                # unexpected disconnect while reading sideband packet". Loopback
                # clients are unaffected, which is why this only bites cross-host
                # fetches (e.g. a mirror/backup daemon cloning over the network).
                proxy_buffering off;
                proxy_request_buffering off;
              '';
            };
          }
          (mkIf (cfg.acmeHost != null) {
            forceSSL = true;
            useACMEHost = cfg.acmeHost;
          })
        ];
      };

      # Optional local Postgres auth wiring for the forgejo system user over the
      # unix socket. Uses upstream services.postgresql string options.
      services.postgresql = mkIf (cfg.database.type == "postgres" && cfg.database.configureLocalAuth) {
        identMap = ''
          forgejo-users ${cfg.database.user} ${cfg.database.user}
        '';
        authentication = ''
          local ${cfg.database.name} ${cfg.database.user} peer
          local ${cfg.database.name} all ident map=forgejo-users
        '';
      };

      users = {
        users.forgejo = mkIf (!cfg.asContainer && cfg.uid != null) {
          uid = mkDefault cfg.uid;
        };
        groups.forgejo = mkIf (!cfg.asContainer && cfg.gid != null) {
          gid = mkDefault cfg.gid;
        };
      };

      systemd.tmpfiles.rules = [
        "d ${cfg.dataDir} 0750 forgejo forgejo - -"
        "d ${cfg.dataDir}/custom 0750 forgejo forgejo - -"
        "d ${cfg.dataDir}/custom/conf 0750 forgejo forgejo - -"
        "d ${cfg.dataDir}/data 0750 forgejo forgejo - -"
        "d ${cfg.dataDir}/log 0750 forgejo forgejo - -"
        "d ${cfg.dataDir}/tmp 0750 forgejo forgejo - -"
      ]
      ++ lib.optionals cfg.useUnixSocket [
        "d /run/forgejo 0755 forgejo forgejo - -"
      ];

      networking.firewall.allowedTCPPorts = lib.mkIf (cfg.sshPort != null && cfg.sshBuiltin) [
        cfg.sshPort
      ];
    }

    (mkIf cfg.asContainer {
      users.users.forgejo = {
        isSystemUser = true;
        group = "forgejo";
        home = cfg.dataDir;
        description = "Forgejo Service";
      }
      // lib.optionalAttrs (cfg.uid != null) { uid = cfg.uid; };
      users.groups.forgejo = lib.optionalAttrs (cfg.gid != null) { gid = cfg.gid; };

      # Templating step: read the DB password from the runtime file and sed it
      # into the app.ini placeholder, then lock down ownership. This keeps the
      # password out of the Nix store while still shipping a fully-rendered
      # config into the data volume.
      systemd.services.podman-forgejo.preStart = lib.mkAfter ''
        uid=${containerUid}
        gid=${containerGid}
        mkdir -p ${cfg.dataDir}/custom/conf
        DB_PASS=$(cat ${toString cfg.database.passwordFile} 2>/dev/null || echo "")
        ${pkgs.gnused}/bin/sed "s|\`FORGEJO_DB_PASSWD\`|$DB_PASS|g" ${appIni} > ${cfg.dataDir}/custom/conf/app.ini
        chmod 600 ${cfg.dataDir}/custom/conf/app.ini
        chown "$uid:$gid" \
          ${cfg.dataDir} \
          ${cfg.dataDir}/custom \
          ${cfg.dataDir}/custom/conf \
          ${cfg.dataDir}/custom/conf/app.ini
      '';

      virtualisation.oci-containers.backend = "podman";
      virtualisation.oci-containers.containers.forgejo = {
        imageFile = forgejoImage;
        image = "forgejo:latest";
        user = "${containerUid}:${containerGid}";
        extraOptions =
          lib.optionals (cfg.containerRuntime != null) [ "--runtime=${cfg.containerRuntime}" ]
          ++ cfg.containerExtraOptions;
        volumes = [
          "${cfg.dataDir}:/data"
        ];
      };
    })

    (mkIf (!cfg.asContainer) {
      services.forgejo = {
        enable = true;
        stateDir = cfg.dataDir;
        lfs.enable = true;
        settings = {
          server =
            if cfg.useUnixSocket then
              {
                PROTOCOL = "http+unix";
                HTTP_ADDR = cfg.unixSocket;
                DOMAIN = cfg.domain;
                ROOT_URL = "https://${cfg.domain}";
              }
            else
              {
                PROTOCOL = "http";
                HTTP_ADDR = "0.0.0.0";
                HTTP_PORT = cfg.httpPort;
                DOMAIN = cfg.domain;
                ROOT_URL = "https://${cfg.domain}";
              };
        };
        database = forgejoDbCfg;
      };
    })

    (mkIf cfg.oidc.enable {
      assertions = [
        {
          assertion = cfg.oidc.clientSecretFile != null;
          message = "OIDC client secret file must be provided when OIDC is enabled";
        }
        {
          assertion = cfg.oidc.discoveryUrl != "";
          message = "OIDC discovery URL must be provided when OIDC is enabled";
        }
      ];

      # Oneshot that waits for both Forgejo and the OIDC discovery endpoint,
      # creates the OAuth login source via the admin CLI (idempotent), then
      # flips is_sync_enabled directly in the DB to enable auto-registration.
      systemd.services.forgejo-oidc-setup = {
        description = "Setup Forgejo OIDC authentication source";
        after = [ "forgejo.service" ];
        wants = [ "forgejo.service" ];
        wantedBy = [ "multi-user.target" ];
        serviceConfig = {
          Type = "oneshot";
          RemainAfterExit = true;
          User = "forgejo";
          Group = "forgejo";
        };
        script = ''
          echo "Waiting for Forgejo to be ready..."

          if [ "${toString cfg.useUnixSocket}" = "1" ]; then
            echo "Using Unix socket: ${cfg.unixSocket}"
            CURL_CMD="${pkgs.curl}/bin/curl -sf --unix-socket ${cfg.unixSocket} http://localhost/"
          else
            echo "Using HTTP port: ${toString cfg.httpPort}"
            CURL_CMD="${pkgs.curl}/bin/curl -sf http://localhost:${toString cfg.httpPort}/"
          fi

          for i in {1..30}; do
            if $CURL_CMD > /dev/null 2>&1; then
              echo "Forgejo is ready"
              break
            fi
            echo "Attempt $i/30: Forgejo not ready yet, waiting..."
            sleep 2
          done

          echo "Checking OIDC provider connectivity..."
          OIDC_READY=0
          for i in {1..60}; do
            if ${pkgs.curl}/bin/curl -sf "${cfg.oidc.discoveryUrl}" > /dev/null 2>&1; then
              echo "OIDC provider is reachable"
              OIDC_READY=1
              break
            fi
            echo "Attempt $i/60: OIDC provider not reachable yet, waiting..."
            sleep 2
          done
          if [ "$OIDC_READY" -eq 0 ]; then
            echo "ERROR: Cannot reach OIDC discovery endpoint after 60 attempts: ${cfg.oidc.discoveryUrl}"
            exit 1
          fi

          CLIENT_SECRET=$(cat ${toString cfg.oidc.clientSecretFile} | tr -d '\n\r ')

          echo "Checking if OAuth source exists..."
          if ${pkgs.forgejo}/bin/forgejo --config ${cfg.dataDir}/custom/conf/app.ini admin auth list | grep -q "${cfg.oidc.authSourceName}"; then
            echo "OAuth source '${cfg.oidc.authSourceName}' already exists, skipping creation"
          else
            echo "Creating OIDC authentication source..."
            # The secret is passed via --secret because Forgejo's `admin auth
            # add-oauth` accepts the client secret only as a CLI argument (no
            # env-var, stdin, or --secret-file input exists upstream). It is
            # therefore briefly visible in /proc/<pid>/cmdline for the lifetime
            # of this single exec. The unit runs as the unprivileged `forgejo`
            # user and the secret is never written to the journal (we never
            # echo it or SELECT the login_source cfg column). If your host is
            # multi-tenant, consider `fs.protected_hardlinks` / a hidepid mount
            # so other local users cannot read this process's cmdline.
            ${pkgs.forgejo}/bin/forgejo --config ${cfg.dataDir}/custom/conf/app.ini admin auth add-oauth \
              --name "${cfg.oidc.authSourceName}" \
              --provider "openidConnect" \
              --key "${cfg.oidc.clientId}" \
              --secret "$CLIENT_SECRET" \
              --auto-discover-url "${cfg.oidc.discoveryUrl}" \
              --scopes "${cfg.oidc.scopes}" \
              ${
                lib.optionalString (
                  cfg.oidc.groupClaimName != ""
                ) "--group-claim-name \"${cfg.oidc.groupClaimName}\""
              } \
              ${lib.optionalString (cfg.oidc.adminGroup != "") "--admin-group \"${cfg.oidc.adminGroup}\""} \
              ${lib.optionalString (
                cfg.oidc.restrictedGroup != ""
              ) "--restricted-group \"${cfg.oidc.restrictedGroup}\""}

            echo "OIDC authentication source created successfully"

            # NOTE: do NOT SELECT the login_source `cfg` column here. Forgejo
            # stores the OAuth client secret inside that JSON blob, so dumping
            # it to stdout would persist the secret in the systemd journal
            # (readable by root and the adm/systemd-journal groups) across
            # reboots. We only touch non-secret columns and never print row
            # contents.
            echo "Attempting to enable auto-registration and account linking..."
            ${pkgs.postgresql}/bin/psql -h /run/postgresql -U ${cfg.database.user} -d ${cfg.database.name} -c "
              UPDATE login_source
              SET is_sync_enabled = true
              WHERE name = '${cfg.oidc.authSourceName}';
            " && echo "Successfully enabled auto-registration!" || echo "Note: Could not enable auto-registration via database"
          fi
        '';
      };
    })
  ]);
}