Skip to content

forgejo-declarative-admin-user

Modules

A NixOS module that declaratively creates and keeps in sync a Forgejo admin user — the kind you need for automated, unattended auth (API scripts, repo mirroring/bisync, CI). A post-startup systemd oneshot ensures the user exists and that its password matches a secret file, so the account lives in your Nix config instead of being clicked into the admin UI once and then forgotten.

The problem

Any automated client that talks to Forgejo over HTTP Basic auth needs a real user with a known password. Creating that user by hand is fine exactly once — but then the password is untracked, rotating it is a manual chore, and rebuilding the host loses the account entirely. You want the user and its current password to be a declared desired state that a redeploy (or a secret rotation) reconciles.

How it works

On boot the module runs a oneshot that:

  1. Waits for Forgejo to answer a readiness probe (readyUrl, optionally over a unix socket).
  2. Runs forgejo admin user create --admin … || true. The || true is deliberate: Forgejo errors out if the user already exists, and that's not a failure we care about — creation is best-effort.
  3. Runs forgejo admin user change-password …. This is the real desired-state enforcer. It runs every time, so it also handles password rotation.

The systemd unit lists passwordFile in restartTriggers, so when that value changes the unit re-runs and pushes the new password into Forgejo. Mind what "changes" means: X-Restart-Triggers records the trigger's text, so it only fires when the path itself differs between generations. A secret manager that hands you a stable runtime path (/run/secrets/…, /run/agenix/…) rotates the file's contents behind an unchanged path, and the unit will not notice.

Rotating the secret is therefore a two-step operation: write the new secret, then systemctl restart forgejo-admin-user.service. The module could have triggered on the file's contents instead — but only by reading the password at evaluation time and hashing it into the unit, which puts the cleartext secret in the world-readable Nix store. Keeping the secret out of the store is worth the manual restart, and change-password is idempotent so re-running it is always safe.

The load-bearing trap: --must-change-password

forgejo admin user change-password (and admin user create) default --must-change-password to true. That flag forces an interactive password change on the account's next login. An unattended client cannot complete an interactive password change — so every automated API call from that user 403s, silently and forever.

The nasty part is that the user gets created perfectly fine; nothing looks wrong until the automation starts failing with 403s that have no obvious cause. You must pass --must-change-password=false explicitly on both the create and the change-password calls. This module always does.

Usage

{
  imports = [ ./modules/forgejo-declarative-admin-user ];

  services.forgejo-admin-user = {
    enable = true;

    username = "automation";
    email = "automation@example.com";
    passwordFile = "/run/secrets/forgejo-admin-password";

    # How to invoke the Forgejo CLI on this host (see below).
    forgejoCli = "${pkgs.forgejo}/bin/forgejo --config /var/lib/forgejo/custom/conf/app.ini";
    serviceUser = "forgejo";

    # Wait for Forgejo, then probe it for readiness.
    afterUnits = [ "forgejo.service" ];
    readyUrl = "http://127.0.0.1:3000/api/v1/version";
  };
}

Adapting to your Forgejo's shape

The CLI has to run against the same config/data dir Forgejo uses, and the two common deployment shapes need different wiring:

Native services.forgejo — call the binary directly, and run the oneshot as the forgejo user so the CLI can read the data dir:

forgejoCli   = "${pkgs.forgejo}/bin/forgejo --config /var/lib/forgejo/custom/conf/app.ini";
serviceUser  = "forgejo";

If that host listens on a unix socket with no TCP port, point the readiness probe at the socket:

readyUrl        = "http://localhost/api/v1/version";
readyUnixSocket = "/run/forgejo/forgejo.sock";

Forgejo in a container — exec into it, and run the oneshot as root:

forgejoCli   = "podman exec -i forgejo forgejo --config /data/custom/conf/app.ini";
serviceUser  = "root";
afterUnits   = [ "podman-forgejo.service" ];
readyUrl     = "http://127.0.0.1:3000/api/v1/version";

Key options

Option Default Purpose
enable false Turn the bootstrap oneshot on
username automation The admin user to create/maintain
email (required) Email for the user (Forgejo requires one)
passwordFile (required) File holding the password; also the restartTriggers source
forgejoCli (required) Shell prefix that runs the Forgejo CLI against this host's config
serviceUser root User the oneshot runs as (forgejo for native, root for container-exec)
afterUnits [ "network-online.target" ] Units to order after / wait for
readyUrl (required) URL that returns 2xx when Forgejo is ready
readyUnixSocket null Probe via this unix socket instead of TCP
readyTimeoutSec 180 How long to wait for readiness before aborting

Caveats

  • The password is passed as a path (passwordFile), read by the oneshot at run time, so it never lands in the Nix store or the unit text — provided you pass a runtime path string. A Nix path literal (./admin-password) would be copied into the world-readable store instead. Point it at any secret manager (agenix, sops-nix, /run/secrets/…).

  • Mirror both sides of a two-Forgejo setup. If you use this to back a bidirectional mirror where one client auths to two Forgejo instances with one shared password, enable the module on each Forgejo host and point every copy at the same secret so username / email / password all match.

  • create is best-effort, change-password is authoritative. If you rename the user (username), the old account is not deleted — the module only ever reconciles the account it's told about.

  • The unit runs wantedBy = multi-user.target with Restart = on-failure. If Forgejo never becomes ready within readyTimeoutSec, the oneshot fails and retries rather than silently doing nothing — check its journal if the user isn't appearing.

Security notes

  • The password is briefly visible in the process table. The Forgejo CLI only accepts the password as a --password <value> argv flag, so for the duration of each create / change-password call the cleartext password is readable via /proc/<pid>/cmdline (or ps auxww) by any local user on the host. The secret still never touches the Nix store or the unit text, but on a multi-user Forgejo box treat the password as exposed to other local accounts during the short window the oneshot runs (at boot and on rotation).

  • serviceUser defaults to root. That default exists for the container-exec shape (podman exec …, which needs root). The oneshot runs your adopter-supplied forgejoCli prefix plus the password read, so with the root default any bug in that command or a compromise of the referenced CLI/secret path executes as root. For a native services.forgejo host set serviceUser = "forgejo" (least privilege — the CLI only needs to read the data dir).

Source

modules/forgejo-declarative-admin-user/default.nix
{
  config,
  lib,
  pkgs,
  ...
}:
let
  cfg = config.services.forgejo-admin-user;

  readyCurlArgs = lib.optionalString (
    cfg.readyUnixSocket != null
  ) "--unix-socket ${lib.escapeShellArg cfg.readyUnixSocket} ";

  bootstrapScript = pkgs.writeShellScript "forgejo-admin-user-bootstrap" ''
    set -eu
    export PATH=${
      lib.makeBinPath [
        pkgs.curl
        pkgs.coreutils
      ]
    }:$PATH

    PW=$(cat ${lib.escapeShellArg cfg.passwordFile})

    echo "Waiting for Forgejo to answer ${cfg.readyUrl} ..."
    for i in $(seq 1 ${toString cfg.readyTimeoutSec}); do
      if curl -sf -o /dev/null ${readyCurlArgs}${lib.escapeShellArg cfg.readyUrl}; then
        echo "Forgejo is up."
        break
      fi
      sleep 1
    done
    if ! curl -sf -o /dev/null ${readyCurlArgs}${lib.escapeShellArg cfg.readyUrl}; then
      echo "Forgejo did not become ready in ${toString cfg.readyTimeoutSec}s — aborting bootstrap."
      exit 1
    fi

    # Idempotent create. `|| true` because forgejo errors on "already exists";
    # the change-password below is the actual desired-state enforcer.
    echo "Ensuring admin user '${cfg.username}' exists ..."
    ${cfg.forgejoCli} admin user create \
      --admin \
      --username ${lib.escapeShellArg cfg.username} \
      --email ${lib.escapeShellArg cfg.email} \
      --password "$PW" \
      --must-change-password=false \
      || true

    # --must-change-password=false is REQUIRED: forgejo's change-password
    # defaults that flag to true, which would force a password change on next
    # login and make the user's automated API calls fail with 403 (an unattended
    # client can't complete an interactive password change). Without it the user
    # is created fine but every API/mirror call 403s.
    echo "Setting password for '${cfg.username}' to current secret value ..."
    ${cfg.forgejoCli} admin user change-password \
      --username ${lib.escapeShellArg cfg.username} \
      --password "$PW" \
      --must-change-password=false

    echo "forgejo-admin-user: done."
  '';
in
{
  options.services.forgejo-admin-user = {
    enable = lib.mkEnableOption "declaratively create + maintain an admin user on this Forgejo host";

    username = lib.mkOption {
      type = lib.types.str;
      default = "automation";
      description = "Username for the managed admin user (used for automated API / mirror auth).";
    };

    email = lib.mkOption {
      type = lib.types.str;
      example = "automation@example.com";
      description = "Email for the managed admin user (required by Forgejo).";
    };

    passwordFile = lib.mkOption {
      type = lib.types.path;
      description = ''
        Path to a file containing the admin user's password. Pass a path from
        whatever secret manager you use (agenix, sops-nix, {file}`/run/secrets/…`).
        Read at run time, so the password itself never enters the Nix store.

        The unit lists this path in {option}`restartTriggers`, but be clear on
        what that can and cannot catch: `X-Restart-Triggers` records the
        trigger's *text*, so it fires only when the path string itself differs
        between generations. A secret manager that rotates the *contents*
        behind a stable runtime path ({file}`/run/secrets/…`,
        {file}`/run/agenix/…`) will NOT trip it, and this module deliberately
        does not hash the file's contents — that would copy the password into
        the world-readable store. **After rotating the secret, restart
        `forgejo-admin-user.service` yourself** (or reboot); the
        `change-password` step is idempotent, so re-running it is always safe.
      '';
      example = "/run/secrets/forgejo-admin-password";
    };

    forgejoCli = lib.mkOption {
      type = lib.types.str;
      description = ''
        Shell command prefix that invokes the Forgejo CLI with the right
        {option}`--config` / {option}`--work-path` for this host. The bootstrap
        script appends `admin user create …` / `admin user change-password …`.

        Native `services.forgejo` (binary called directly; run the unit as the
        forgejo user so the CLI can read the data dir):

        ```
        "''${pkgs.forgejo}/bin/forgejo --config /var/lib/forgejo/custom/conf/app.ini"
        ```

        Forgejo running inside a container (exec into it; run the unit as root):

        ```
        "podman exec -i forgejo forgejo --config /data/custom/conf/app.ini"
        ```
      '';
      example = "\${pkgs.forgejo}/bin/forgejo --config /var/lib/forgejo/custom/conf/app.ini";
    };

    serviceUser = lib.mkOption {
      type = lib.types.str;
      default = "root";
      description = ''
        Which user the bootstrap oneshot runs as. Use `"forgejo"` for a native
        `services.forgejo` host (the CLI must be able to read the data dir), or
        `"root"` for a container-exec wrapper.
      '';
    };

    afterUnits = lib.mkOption {
      type = lib.types.listOf lib.types.str;
      default = [ "network-online.target" ];
      example = [ "forgejo.service" ];
      description = "Systemd units this bootstrap must wait for before running (e.g. the Forgejo service unit).";
    };

    readyUrl = lib.mkOption {
      type = lib.types.str;
      description = ''
        HTTP URL that returns 2xx when Forgejo is ready — typically the local API
        version probe. For a unix-socket Forgejo set this to a host-less URL such
        as `http://localhost/api/v1/version` and point {option}`readyUnixSocket`
        at the socket.
      '';
      example = "http://127.0.0.1:3000/api/v1/version";
    };

    readyUnixSocket = lib.mkOption {
      type = lib.types.nullOr lib.types.path;
      default = null;
      example = "/run/forgejo/forgejo.sock";
      description = ''
        If set, the readiness probe connects via this unix socket
        (`curl --unix-socket`) instead of opening a TCP connection. Required for a
        native `services.forgejo` host that listens on a socket with no TCP port.
      '';
    };

    readyTimeoutSec = lib.mkOption {
      type = lib.types.int;
      default = 180;
      description = "How long to wait for readyUrl before aborting.";
    };
  };

  config = lib.mkIf cfg.enable {
    systemd.services.forgejo-admin-user = {
      description = "Bootstrap / maintain the managed admin user on this Forgejo host";
      after = cfg.afterUnits;
      wants = cfg.afterUnits;
      wantedBy = [ "multi-user.target" ];
      restartTriggers = [ cfg.passwordFile ];
      serviceConfig = {
        Type = "oneshot";
        RemainAfterExit = true;
        Restart = "on-failure";
        RestartSec = 10;
        User = cfg.serviceUser;
        ExecStart = bootstrapScript;
      };
    };
  };
}