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:
- Waits for Forgejo to answer a readiness probe (
readyUrl, optionally over a unix socket). - Runs
forgejo admin user create --admin … || true. The|| trueis deliberate: Forgejo errors out if the user already exists, and that's not a failure we care about — creation is best-effort. - 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:
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. -
createis best-effort,change-passwordis 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.targetwithRestart = on-failure. If Forgejo never becomes ready withinreadyTimeoutSec, 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 eachcreate/change-passwordcall the cleartext password is readable via/proc/<pid>/cmdline(orps 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). -
serviceUserdefaults toroot. That default exists for the container-exec shape (podman exec …, which needs root). The oneshot runs your adopter-suppliedforgejoCliprefix 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 nativeservices.forgejohost setserviceUser = "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;
};
};
};
}