Nixbot
Nixbot is a continuous integration (CI) service for the Nix ecosystem, shipped as a single NixOS service. It is a rewrite of buildbot-nix, its spiritual ancestor.
What started as a set of Buildbot plugins now runs standalone. One server handles forge webhooks, nix-eval-jobs evaluation, builds through the local nix daemon (offloaded via remote builders), commit statuses, and its own web frontend.
Chat with us on Matrix: #nixbot:thalheim.io
Features
- Fast, Parallel evaluation using nix-eval-jobs
- Gitea/Github integration:
- Login with GitHub to control builds
- CI status notification in pull requests and on the default branch
- Embeddable SVG build-status badges for READMEs
- All builds share the same nix store for speed
- Tested with large flakes (50k flake outputs per evaluation)
- The last attribute of a build is protected from garbage collection
- Build matrix based on
.#checksplus theherculesCIonPush jobs of your flake - No arbitrary code runs outside of the Nix sandbox
- hercules-ci effects to run impure CI steps i.e. deploying NixOS, with dependency ordering and named locks
Getting Started
To set up nixbot, start by exploring the provided examples:
- Check out the basic setup in example.
- The NixOS module lives in nixosModules/nixbot.nix.
- For local development, see Local Development Guide.
Additionally, you can find real-world examples at the end of this document.
The service runs on one machine; to support multiple architectures and to scale out builds, configure nix remote builders. For a practical NixOS example, see this remote builder configuration.
Migrating from buildbot-nix
Nixbot replaces the buildbot master/worker pair with a single service. See the migration guide for the full upgrade instructions.
Using Nixbot in Your Project
Nixbot automatically triggers builds for your project under these conditions:
- When a pull request is opened.
- When a commit is pushed to the default git branch.
It evaluates the .#checks attribute of your project’s flake in parallel; each
attribute results in a separate build step (named
default.checks.<system>.<name>). You can test these builds locally using
nix flake check -L or
nix-fast-build.
If your flake also defines a herculesCI output (by hand or via
hercules-ci-effects),
nixbot additionally follows
hercules-ci-agent’s evaluation
semantics: every
onPush.<job>.outputs attribute is built (named <job>.<path>),
outputs.effects run as effects, ciSystems filters
per-system outputs, and the buildDependenciesOnly, noBuildPhase,
ignoreFailure and requireFailure build modifiers are honored.
If you need to build other parts of your flake, such as packages or NixOS
machines, add them to onPush.<job>.outputs or re-export them into the
.#checks output. Here are two examples to guide you:
- Using flake-parts.
- A plain flake example.
Authentication backend
Anonymous users get read-only access to public projects; private repositories
and their builds are only visible to users with access on the forge. For write
actions (restart, cancel) a login is required. Every enabled forge with OAuth
credentials configured offers a login, OIDC via services.nixbot.oidc.enable;
several providers can be active at once.
We have the following roles:
- Admins (hard-coded in the NixOS configuration): reload the project list, enable/disable projects, restart/cancel any build.
- Repo writers (forge write access): restart/cancel that repository’s builds.
- PR authors: restart/cancel their own pull request’s builds.
GitHub Integration
Nixbot uses GitHub App authentication to integrate with GitHub repositories. This enables automatic webhook setup, check-run reporting (with a working Re-run button), and secure authentication.
See the GitHub documentation for setup instructions.
Gitea Integration
Nixbot integrates with Gitea using access tokens for repository management and OAuth2 for user authentication. This enables automatic webhook setup, commit status updates, and secure authentication.
See the Gitea documentation for setup instructions.
GitLab Integration
Token-based integration with per-repository webhooks and commit status updates.
See the GitLab documentation for setup instructions.
Generic OIDC Authentication
Nixbot supports generic OpenID Connect (OIDC) authentication, allowing you to use any OIDC-compliant identity provider (Keycloak, PocketID, Authentik, etc.) for user login.
See the OIDC documentation for configuration details.
Per Repository Configuration
Currently nixbot will look for a file named nixbot.toml (falling back to the
legacy buildbot-nix.toml) in the root of whichever branch it’s currently
evaluating, parse it as TOML and apply the configuration specified. See
examples/nixbot.toml for a commented example with all
supported options.
By default, effects only run on the default branch. The build_branches,
effects_branches and effects_on_pull_requests settings are always read from
the default branch’s nixbot.toml (via git show) so that pull request
authors cannot grant themselves builds or effects access.
⚠️ Security warning: PR effects receive the same
effects_per_repo_secretsas default-branch effects. A malicious PR can modify the effect code to exfiltrate these secrets. Only enableeffects_on_pull_requestsfor repositories where you trust all contributors, or where no secrets are configured.
Status badges
The repository page has a “status badge” panel with copyable README markup.
Append ?branch=<name> for a branch other than the default.
Binary caches
To access the build results on other machines there are two options at the moment
Local binary cache (harmonia)
You can set up a binary cache on the CI machine to make its nix store accessible from other machines. Check out the README of the project, for an example configuration
Cachix
Nixbot also supports pushing packages to cachix via the services.nixbot.cachix
options.
Attic
Nixbot does not have native support for pushing packages to attic yet. However it’s possible to integrate run a systemd service as described in this example configuration. The systemd service watches for changes in the local nix store and uploads the contents to the attic cache.
Hercules CI effects
See docs/EFFECTS.md for CLI usage, flake reference support, and secrets configuration.
Incompatibilities with the lix overlay
The lix overlay overrides nix-eval-jobs with a version that doesn’t work with nixbot because of missing features and therefore cannot be used together with the nixbot module.
Possible workaround: Don’t use the overlay and only set the
nix.package = pkgs.lix; NixOS option.
Alternatives
- Garnix - Fully hosted, zero-config CI for flakes.
- Hercules CI - Hosted CI with self-hosted agents. Nixbot’s effects system is inspired by theirs.
- Hydra - The original Nix CI, powers nixos.org. Written in Perl, not recommended for new deployments.
Real-World Deployments
See Nixbot in action in these deployments:
- Mic92’s dotfiles: Configuration | Instance
- Nix-community infra: Configuration | Instance
- Technical University Munich: Configuration | Instance
Need commercial support or customization?
For commercial support, please contact Mic92 at joerg@thalheim.io.
GitHub Integration
Buildbot-nix uses GitHub App authentication to integrate with GitHub repositories. This enables automatic webhook setup, check-run reporting, and secure authentication.
Step 1: Create a GitHub App
-
Navigate to:
- For personal accounts:
https://github.com/settings/apps/new - For organizations:
https://github.com/organizations/<org>/settings/apps/new
- For personal accounts:
-
Configure the app with these settings:
- GitHub App Name:
nixbot-<org>(or any unique name) - Homepage URL:
https://nixbot.<your-domain> - Webhook: Enable (Active) and set:
- Webhook URL:
https://nixbot.<your-domain>/webhooks/github - Webhook secret: the same value as
webhookSecretFilebelow
- Webhook URL:
- Callback URL (optional, for OAuth):
https://nixbot.<your-domain>/auth/github/callback
- GitHub App Name:
-
Set the required permissions:
- Repository Permissions:
- Contents: Read-only (to clone repositories), (read and write is required for effects to push to branches)
- Checks: Read and write (to report build status as check runs)
- Metadata: Read-only (basic repository info)
- Pull requests: Read-only (required to subscribe to the pull_request
event), (read and write is required for effects to create PRs or comment
via
nixbot-pr-comment) - Issues: Read-only (required to subscribe to the issue_comment event for
onEvent.commenteffects)
- Organization Permissions (if app is for an organization):
- Members: Read-only (to verify organization membership for access control)
- Subscribe to events: Push, Pull request, Check run, Check suite, Issue comment
Note: when adding permissions to an existing app, every installation (your user account and each organization) must accept the new permissions under Settings → GitHub Apps → Configure before events are delivered.
- Repository Permissions:
-
After creating the app:
- Note the App ID
- Generate and download a private key (.pem file)
Step 2: Configure nixbot
Add the GitHub configuration to your NixOS module:
services.nixbot = {
enable = true;
domain = "nixbot.example.com"; # Your nixbot domain
github = {
enable = true;
appId = <your-app-id>; # The numeric App ID
appSecretKeyFile = "/path/to/private-key.pem"; # Path to the downloaded private key
# OAuth credentials enable the GitHub login button
oauthId = "<oauth-client-id>";
oauthSecretFile = "/path/to/oauth-secret";
# A random secret used to verify incoming webhooks from GitHub
webhookSecretFile = "/path/to/webhook-secret";
# Optional: only allow these owners/repositories to be built
userAllowlist = [ "my-org" ];
repoAllowlist = [ "other-org/repo" ];
# One-shot import: repositories with this topic are enabled on first
# startup with an empty database; afterwards manage projects in the web UI
topic = "build-with-buildbot";
};
};
Step 3: Install the GitHub App
- Go to your app’s settings page
- Click “Install App” and choose which repositories to grant access
- The app needs access to all repositories you want to build with nixbot
Step 4: Repository Configuration
For each repository you want to build:
-
Enable the project:
- Toggle the project on in the web UI (as admin)
-
Webhook delivery:
- GitHub delivers push, pull_request, issue_comment, check_run and check_suite events through the App-level webhook configured in Step 1; no per-repository webhooks are created.
- The endpoint is
https://nixbot.<your-domain>/webhooks/github(the legacy/change_hook/githubpath also works).
How It Works
- Authentication: Uses GitHub App JWT tokens for API access and installation tokens for repository-specific operations
- Project Discovery: Automatically discovers repositories the app has access
to (restricted by
userAllowlist/repoAllowlistif set); discovered projects are built once enabled in the web UI - Webhook Delivery: Push, pull_request, issue_comment, check_run and check_suite events arrive via the GitHub App webhook; the payload signature is verified with the webhook secret
- Status Updates: Reports build status as Check Runs (with markdown log excerpts and a working Re-run button) on commits and pull requests
- Access Control:
- Admins: Configured users can reload projects and manage builds
- Repo writers: Users with write access to the repo can restart/cancel its builds
- PR authors: Can restart/cancel the builds of their own pull request
Repository permissions
nixbot maps a user’s GitHub collaborator permission onto its own roles. Each level includes the ones above it:
| GitHub permission | Allowed in nixbot |
|---|---|
| read | view the project, including private repositories |
| write / maintain | restart and cancel the project’s builds |
| admin | enable or disable the project |
The lookup uses the App’s own credentials
(GET /repos/{owner}/{repo}/collaborators/{user}/permission) and only needs the
Metadata (read) permission that every GitHub App has, so no extra configuration
is required. The user’s login token merely proves identity (read:user scope),
which is why the same rules apply to web sessions and personal API tokens.
Independent of repository permissions, instance admins can do everything, and PR authors can restart or cancel the builds of their own pull requests.
Approving outside contributors
Off by default. With services.nixbot.prApproval.enable = true pull requests
whose author_association is not in prApproval.trustedAssociations (default
OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR) are not built. Pull requests
whose head branch is in the repository itself (GitHub Apps, bots, maintainers
pushing branches) always build, since that already required write access
(repositories opt out with require_approval = false in the default branch’s
nixbot.toml). Instead the head commit gets an action_required check run with
an Approve CI button. GitHub shows that button only to users with write
access. The project page in the web UI offers the same to repo writers and
instance admins (POST /api/repos/github/<owner>/<repo>/pulls/<n>/approve).
Approval unlocks the whole pull request: the held head builds immediately and
later pushes build without asking again. Gitea and GitLab are unaffected.
Troubleshooting
-
Projects not appearing: Check that:
- The GitHub App is installed for the repository
- The repository is not excluded by
userAllowlist/repoAllowlist - Reload projects manually through the web UI
-
Project appears but nothing builds: Enable the project in the web UI
-
No builds on push: Verify the App webhook is Active, its URL points to
https://nixbot.<your-domain>/webhooks/github, and its secret matcheswebhookSecretFile. Check recent deliveries under the app’s “Advanced” tab. -
Authentication issues: Ensure the private key file is readable by the nixbot service
Gitea Integration
Buildbot-nix integrates with Gitea using access tokens for repository management and OAuth2 for user authentication. This enables automatic webhook setup, commit status updates, and secure authentication.
Step 1: Create a Gitea Access Token
-
Create a dedicated Gitea user (recommended for organizations):
- This user will manage webhooks and report build statuses
- Add this user as a collaborator with Administrator permission to every
repository you want to build. Repo admin is required for:
- Webhooks: Gitea only lets repo admins manage webhooks. Without admin
the repository is still discovered, but the webhook must be created
manually: POST to
https://<domain>/webhooks/giteawith the events push, pull_request, pull_request_sync, and for onEvent effects also pull_request_comment and pull_request_label. - Per-user visibility: nixbot decides who may see a private repository
by asking
GET /repos/{owner}/{repo}/collaborators/{user}/permissionwith this token. This endpoint is only available to repo admins (or site admins). Without admin, private repositories are hidden from everyone but nixbot instance admins. - onEvent permissions:
when.permissionand thepermissionfield in event payloads come from the same endpoint. Without admin they arenull, so/commandeffects gated on a permission never run.
- Webhooks: Gitea only lets repo admins manage webhooks. Without admin
the repository is still discovered, but the webhook must be created
manually: POST to
- Private repositories are invisible to the token until the user is added.
-
Generate an access token:
- Log in as the dedicated user
- Go to Settings → Applications → Generate New Token
- Required permissions:
write:repository- To create webhooks and update commit statuseswrite:issue- To post pull request comments from onEvent effects (nixbot-pr-comment) and reply to/commandcommentsread:user- To list the repositories the user has access to
- Save the token securely
Step 2: Set up OAuth2 Authentication (for user login)
-
Create an OAuth2 Application:
- Navigate to one of these locations:
- Site Administration → Applications (for admins, applies globally)
- Organization Settings → Applications (for organization-wide access)
- User Settings → Applications (for personal use)
- Navigate to one of these locations:
-
Configure the OAuth2 app:
- Application Name:
nixbot - Redirect URI:
https://nixbot.<your-domain>/auth/gitea/callback
- Application Name:
-
Note the credentials:
- Client ID
- Client Secret
Step 3: Configure nixbot
Add the Gitea configuration to your NixOS module:
services.nixbot = {
gitea = {
enable = true;
instanceUrl = "https://gitea.example.com";
# Access token for API operations
tokenFile = "/path/to/gitea-token";
# OAuth2 for user authentication
oauthId = "<oauth-client-id>";
oauthSecretFile = "/path/to/oauth-secret";
# Optional: SSH authentication for private repositories and
# private ssh:// flake inputs. Anyone who can open a pull request
# against a built repository can add any repo this key can read as
# a flake input and thus read its contents. Use a deploy key or
# machine user scoped to the repositories you intend to expose,
# not a forge-wide/admin key.
sshPrivateKeyFile = "/path/to/ssh-key";
sshKnownHostsFile = "/path/to/known-hosts";
# Optional: only allow these owners/repositories to be built
userAllowlist = [ "my-org" ];
repoAllowlist = [ "other-org/repo" ];
# One-shot import: repositories with this topic are enabled on first
# startup with an empty database; afterwards manage projects in the web UI
topic = "build-with-buildbot";
};
};
If webhooks must reach nixbot under a different URL than the web UI, set
services.nixbot.webhookBaseUrl.
Step 4: Repository Configuration
For each repository you want to build:
-
Grant repository access:
- Add the nixbot user as a collaborator with admin access
- Admin access is required for webhook creation
-
Enable the project:
- Toggle the project on in the web UI (as admin)
-
Automatic webhook creation:
- Webhooks are created for enabled projects on every discovery cycle
(startup, periodic refresh, manual reload) at
https://nixbot.<your-domain>/webhooks/gitea - Each repository gets an auto-generated secret stored in the database; existing hooks are re-synced in place, leftover buildbot-era hooks pointing at this instance are removed
- Webhook events:
push,pull_requestandpull_request_sync
- Webhooks are created for enabled projects on every discovery cycle
(startup, periodic refresh, manual reload) at
-
Manual webhook creation (only when the nixbot user is not a repo admin; watch for the “no admin permission to manage webhooks” warning):
- Enable the project
- As a nixbot admin, open the repository page in the nixbot web UI, expand webhook setup and press regenerate - the secret is shown exactly once (rotating later invalidates the old secret; auto-managed hooks pick the new one up on the next discovery cycle)
- In the Gitea repository: Settings → Webhooks → Add Webhook → Gitea
- Target URL and Secret from step 2, POST Content Type
application/json - Trigger On: Custom Events → check Push, Pull Request, and Pull Request Synchronized
- Activate and save
How It Works
- Authentication: Uses Gitea access tokens for API operations
- Project Discovery: Automatically discovers repositories where the nixbot
user has admin access (restricted by
userAllowlist/repoAllowlistif set); discovered projects are built once enabled in the web UI - Webhook Management: Automatically creates and manages webhooks (push and pull request events) for enabled projects
- Status Updates: Reports build status back to Gitea commits and pull requests
- Access Control:
- Admins: Configured users can reload projects and manage builds
- Repo writers: Users with write access to the repo can restart/cancel its builds (when OAuth is configured)
- PR authors: Can restart/cancel the builds of their own pull request
- Repository Access: Can use either HTTPS (with token) or SSH authentication for cloning private repositories
Troubleshooting
-
Projects not appearing: Check that:
- The nixbot user has admin access to the repository
- The repository is not excluded by
userAllowlist/repoAllowlist - The access token has the correct permissions
- Reload projects manually through the web UI
-
Project appears but nothing builds: Enable the project in the web UI
-
Webhooks not created: Verify the project is enabled and the nixbot user has admin permissions on the repository
-
Authentication issues:
- Ensure the access token is valid and has required permissions
- For OAuth issues, verify the redirect URI matches exactly
-
Private repositories: If using SSH, ensure the SSH key is properly configured and the known_hosts file contains the Gitea server
GitLab Integration
Buildbot-nix integrates with GitLab using an access token. GitLab has no GitHub-App equivalent, so the setup follows the Gitea model: token-based API access, per-repository webhooks, commit status updates.
Step 1: Create a GitLab Access Token
Use one of:
- Personal access token (simplest; CI acts as your user): User Settings →
Access tokens → Add new token, scope
api - Group or project access token (separate bot identity): Group/Project
Settings → Access tokens, role Maintainer, scope
api - Service account (self-managed admin or paid tier)
Maintainer permission on a project is required for automatic webhook registration; without it the project is still discovered and built, but the webhook must be created manually (see below).
Step 2: Configure nixbot
services.nixbot = {
gitlab = {
enable = true;
# instanceUrl defaults to https://gitlab.com
tokenFile = "/path/to/gitlab-token";
# Optional: restrict which repositories are built
userAllowlist = [ "mygroup" ];
# repoAllowlist = [ "mygroup/myrepo" ];
# Optional: SSH authentication for fetching and for private ssh://
# flake inputs. Anyone who can open a merge request against a built
# repository can add any repo this key can read as a flake input and
# thus read its contents. Use a deploy key or machine user scoped to
# the repositories you intend to expose, not an instance-wide key.
# sshPrivateKeyFile = "/path/to/ssh-key";
# sshKnownHostsFile = "/path/to/known-hosts";
};
};
Step 3: Enable Projects
- Open the nixbot web UI as an admin
- Enable the repository on the dashboard
- With Maintainer permission the webhook (push + merge request events) is registered automatically on the next discovery cycle
Manual webhook creation
Only needed when the token lacks Maintainer on the project (watch for the “no maintainer permission to manage webhooks” warning):
- Enable the project
- On the repository page in the nixbot web UI, expand webhook setup and press regenerate - the secret is shown exactly once
- In GitLab: Settings → Webhooks → Add new webhook
- URL from step 2, Secret token from step 2
- Trigger: Push events and Merge request events
- Save
Notes
- Commit statuses are posted per attribute plus
nixbot/nix-eval; use them in merge request approval rules / merged results pipelines. - GitLab does not sign webhook payloads; the secret is compared against the
X-Gitlab-Tokenheader. Use HTTPS for the webhook URL. - Access control (with OAuth login): admins manage everything; Developer access or higher to a project allows restart/cancel of its builds; MR authors can restart/cancel their own MR’s builds.
Effects
An effect is a script that changes something outside the build: pushing an image, deploying a machine, publishing a release, commenting on a pull request. nixbot builds everything an effect needs first, then runs it in a sandbox with access to the Nix store, the network and the secrets you configured.
Effects follow
Hercules CI effects: a
flake that works there works here, with
hercules-ci-effects as the
library; mkEffect is also available from nixbot’s
effects-lib. nixbot adds ordering and locks,
skipping unchanged effects, tag and event effects, and the nbo effects
command.
Your first effect
Effects live in the herculesCI output of your flake. Every onPush.<job> is
evaluated, and the attributes of its outputs.effects are the effects:
# flake.nix
{
inputs.nixbot.url = "github:Mic92/nixbot";
outputs = { self, nixpkgs, nixbot, ... }: {
herculesCI = { primaryRepo, ... }: let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
inherit (nixbot.lib.effects { inherit pkgs; }) mkEffect;
in {
onPush.default.outputs.effects.notify = mkEffect {
inputs = [ pkgs.curl ];
effectScript = ''
curl -sf -d "built ${primaryRepo.rev}" https://chat.example.org/hook
'';
};
};
};
}
Push this to the default branch. Once the build is green, notify runs and its
log appears on the build page, next to the build’s other results. The effects
commit status shows whether all effects succeeded.
Try it before pushing with nbo effects run default.notify, see
Running effects locally.
When effects run
- A push to the default branch runs its
onPusheffects after the build succeeded. Other branches and pull requests only build the effects’ dependencies and report problems as “Effect checks”, see Checks on every build. effects_branchesandeffects_on_pull_requestsinnixbot.tomlallow effects on more branches or on pull requests. nixbot reads them from the default branch, so a pull request cannot enable itself. See Per Repository Configuration. Pull request effects get the same secrets, so only enable them for repositories you trust all contributors of.- Pushing a tag runs the effects too, see Tag pushes.
- Event effects run when a pull request turns green, a comment asks for it, and similar events.
A build’s effects start after all its builds succeeded and run in parallel, unless you order them.
Running effects locally
nbo effects (part of the nbo CLI) lists, graphs and runs
effects without pushing, also for a remote flake reference:
$ nbo effects list
$ nbo effects graph
$ nbo effects run default.deploy
$ nbo effects run github:org/repo/branch#default.deploy
Effects are named by their onPush job and attribute path, default.deploy for
onPush.default.outputs.effects.deploy. For secrets see
Running locally with secrets. --secrets FILE
and nbo effects --help list the remaining flags.
Ordering effects
Two attributes on an effect control when it runs:
after: effects of the same build that must succeed first, each as[ "<job>" "<attribute>" ... ], e.g.[ [ "default" "push-image" ] ]. If a dependency fails, the effect isskippedand the build is reported as failed. Effects not ordered against each other run in parallel. Cycles or unknown paths fail effect discovery.lock: a named lock. Effects holding the same lock run one at a time per project, across builds and pull requests, which suits a staging environment or a hardware lab. A lock is handed out in build order, so the effects of two builds never interleave.
Both are plain attributes next to effectScript. Hercules CI ignores them.
herculesCI = { primaryRepo, ... }: {
onPush.default.outputs.effects = {
push-image = mkEffect {
inputs = [ pkgs.skopeo ];
effectScript = ''
skopeo copy docker-archive:${self.packages.x86_64-linux.image} \
docker://registry.example.org/app:${primaryRepo.rev}
'';
};
deploy-staging = mkEffect {
after = [ [ "default" "push-image" ] ];
lock = "staging";
inputs = [ pkgs.kubectl ];
effectScript = "kubectl set image deployment/app app=registry.example.org/app:${primaryRepo.rev}";
};
deploy-prod = mkEffect {
after = [ [ "default" "deploy-staging" ] ];
lock = "prod";
inputs = [ pkgs.kubectl ];
effectScript = "kubectl --context prod set image deployment/app app=registry.example.org/app:${primaryRepo.rev}";
};
# No after or lock: starts immediately.
notify = mkEffect {
inputs = [ pkgs.curl ];
effectScript = ''curl -sf -d "deployed ${primaryRepo.rev}" https://chat.example.org/hook'';
};
};
};
One push runs push-image and notify at once, then deploy-staging, then
deploy-prod. If push-image fails, both deploys are skipped and the commit
status is red. nbo effects graph shows the result:
$ nbo effects graph
default.notify
default.push-image
└── default.deploy-staging [lock: staging]
└── default.deploy-prod [lock: prod]
Skipping unchanged effects
when.changed names the inputs an effect depends on. The effect is not run
again while they are the same as in its last successful run:
let
firmware = self.packages.x86_64-linux.image;
flasher = pkgs.hello;
in
mkEffect {
lock = "rig";
when.changed = { inherit firmware flasher; };
effectScript = "flash ${firmware} && run-tests";
}
Values must be strings, typically store paths: up to 32 inputs of at most 1024
bytes each. When the effect’s turn comes (after its lock) and the last
successful run of this effect in the repository had the same inputs, it is
marked succeeded as “unchanged since build #N” without running. Effects after
it run as usual.
Failed runs and runs for pull requests do not count. A restart always runs the
effect. Only onPush effects support when.changed, and no other when key.
Secrets and credentials
An effect asks for secrets with secretsMap, which maps the name the script
uses to a secret nixbot holds. The sections below configure the secrets, read
them in a script and write the credential files common tools expect.
Configuring secrets on the server
Secrets are configured per repository or per organization:
- Repository-specific:
"github:owner/repo"— applies to a single repository - Organization-wide:
"github:org/*"— applies to all repositories in an organization
services.nixbot.effects.perRepoSecretFiles = {
# All repos in nix-community org get this token
"github:nix-community/*" = config.agenix.secrets.nix-community-effects.path;
# This specific repo gets its own token (overrides org-level)
"github:nix-community/nixbot" = config.agenix.secrets.nixbot-effects.path;
# All repos in a Gitea org
"gitea:my-org/*" = config.agenix.secrets.my-org-effects.path;
};
The secrets files must be valid JSON files containing the secrets that will be made available to your effects at runtime.
Reading secrets
In the shell functions below, a secret NAME is a key of the effect’s
secretsMap, or of the secrets JSON if it has none.
readSecretString NAME PATHprints a string from the secret’sdata.PATHis ajqpath. It fails if the path doesn’t exist.readSecretJSON NAME PATHprints any value as compact JSON.
mkEffect {
# Secret "cloud": { "data": { "token": "...", "regions": ["eu", "us"] } }
secretsMap.cloud = "cloud";
inputs = [ pkgs.curl ];
effectScript = ''
token=$(readSecretString cloud .token)
regions=$(readSecretJSON cloud .regions) # ["eu","us"]
curl -H "Authorization: Bearer $token" -d "$regions" https://example.org
'';
}
Writing credentials
| Function | Secret fields | Result |
|---|---|---|
writeSSHKey [NAME=ssh] [FILE] | privateKey, optional publicKey | ~/.ssh/id_rsa (mode 0400) and .pub |
writeAWSSecret [NAME=aws] [PROFILE] | aws_access_key_id, aws_secret_access_key | profile in ~/.aws/credentials |
writeDockerKey [NAME=docker] [DIR] | clientKey, clientCertificate, CACertificate | key.pem, cert.pem, ca.pem in ~/.docker |
writeGPGKey [NAME=gpg] | privateKey | key imported into the keyring and fully trusted |
useDockerHost HOST [PORT=2376] points Docker at a remote host with TLS.
Add the tools these call to inputs: pkgs.openssh (ssh-keygen, used when
the secret has no publicKey) and pkgs.gnupg.
mkEffect {
inputs = [ pkgs.git pkgs.openssh ];
secretsMap.ssh = "deploy-key";
effectScript = ''
writeSSHKey ssh # ~/.ssh/id_rsa
git push git@example.org:org/repo.git HEAD:refs/heads/deploy
'';
}
Running locally with secrets
Pass --secrets to provide secrets when running effects locally. The file is a
JSON object where each key is a secret name and its value has a "data" field
containing key-value pairs:
{
"my-secret": {
"data": {
"token": "ghp_xxxxxxxxxxxx",
"username": "deploy-bot"
}
}
}
$ nbo effects run --secrets secrets.json default.deploy
Inside the effect, secrets are available at /run/secrets.json (via
HERCULES_CI_SECRETS_JSON). This follows the
hercules-ci secrets format.
State files and phases
State files
An effect can keep small files between runs, per project:
getStateFile NAME [FILE]downloads stateNAMEtoFILE(defaultNAME). If the state doesn’t exist yet,FILEis removed.putStateFile NAME [FILE]uploadsFILE.
Call them from getStateScript and putStateScript. State is also uploaded
when the effect fails.
mkEffect {
getStateScript = "getStateFile counter";
effectScript = "echo $(( $(cat counter 2>/dev/null || echo 0) + 1 )) > counter";
putStateScript = "putStateFile counter";
}
Phases
Phases run in this order, each wrapped in pre<Phase> and post<Phase> hooks:
| Phase | Script |
|---|---|
initPhase | |
getStatePhase | getStateScript |
userSetupPhase | userSetupScript |
priorCheckPhase | priorCheckScript |
effectPhase | effectScript |
putStatePhase | putStateScript |
effectCheckPhase | effectCheckScript |
A failing priorCheckScript only prints a warning, because the effect may
repair the problem. A failing effectCheckScript fails the effect. Add your own
phases with preGetStatePhases, preEffectPhases and postEffectPhases.
mkEffect {
inputs = [ pkgs.curl ];
priorCheckScript = "curl -sf https://example.org/health";
effectScript = "deploy";
effectCheckScript = "curl -sf https://example.org/health";
}
Deploying to other machines
effects-lib has hercules-ci-effects’ helpers for deploying over SSH, with the
same arguments. runNixOS and runNixDarwin switch a machine to a
configuration. ssh runs a script on a machine.
None of them sets up an SSH key. Keep the key in a secret and write it with
writeSSHKey in userSetupScript, as the examples do.
Switching a machine to a configuration
let
inherit (nixbot.lib.effects { inherit pkgs; }) runNixOS runNixDarwin;
in
{
deploy-rig = runNixOS {
configuration = self.nixosConfigurations.rig;
ssh.destination = "root@rig";
secretsMap.ssh = "deploy-key";
userSetupScript = "writeSSHKey ssh";
};
deploy-mac = runNixDarwin {
configuration = self.darwinConfigurations.mac;
ssh.destination = "admin@mac";
secretsMap.ssh = "deploy-key";
userSetupScript = "writeSSHKey ssh";
};
}
runNixOS builds the configuration, copies it to the host and runs
switch-to-configuration there. runNixDarwin does the same with
darwin-rebuild activate, through sudo unless the SSH user is root or can
write the profile directory. The system that gets deployed is the effect’s
passthru.prebuilt.
configuration: an evaluated configuration, likeself.nixosConfigurations.rig. A module works too.runNixOSevaluates it withsystemandnixpkgs,runNixDarwinwithnix-darwin,systemandpkgs(ornixpkgs).ssh: the connection, with the options ofssh. Onlydestinationis required.buildOnDestination: shorthand forssh.buildOnDestination.profile(runNixOSonly): the profile to set. The default is/nix/var/nix/profiles/system.- Everything else goes to
mkEffect.runNixDarwinreplaceseffectScript, so write the key inuserSetupScript.
Running a script on a host
ssh copies the closure of a script to a host with nix-copy-closure and runs
the script there over ssh. It returns shell code for an effectScript:
let
inherit (nixbot.lib.effects { inherit pkgs; }) mkEffect ssh;
in
mkEffect {
inputs = [ pkgs.openssh ];
secretsMap.ssh = "deploy-key";
userSetupScript = "writeSSHKey ssh";
effectScript = ''
rev=v1.2.3
${ssh { destination = "root@rig"; inheritVariables = [ "rev" ]; } ''
nixos-rebuild switch --flake github:org/repo/$rev
''}
'';
}
Options:
destination(required): the host, asuser@host.inheritVariables: variables of the effect that the script can read.sshOptions,nix-copy-closureOptions: extra arguments forsshandnix-copy-closure.compress: compress the copy and the session.compressClosureandcompressSessionset them separately.useSubstitutes(defaulttrue): let the host fetch paths from its substituters.buildOnDestination: build on the host. NeedsdestinationPkgs, a nixpkgs that can be built there.
Pushable repository checkout
Effects that modify the repository (auto-updates, formatting bots) can ask nixbot for a ready-made working copy instead of cloning inside the sandbox:
effects.flake-update = mkEffect {
checkout = true;
effectScript = ''
nix flake update
git commit -am "flake.lock: update"
git push origin HEAD:update-flake-lock
'';
};
nixbot clones the repository from its local mirror at the commit the effect runs
for and mounts it writable at /build/checkout, which is also the effect’s
working directory (and exported as NIXBOT_EFFECT_CHECKOUT). The clone’s
origin uses the forge token, so git push works without extra secrets. The
clone is removed after the effect finishes.
The checkout argument of mkEffect sets the __nixbot_effect_checkout
derivation attribute; effects built without mkEffect can set the attribute
directly. Effects that do not set it get no checkout. If the repository has no
forge token to push with, the effect fails with an error.
Tag pushes
Pushing a tag runs the tagged commit’s onPush effects with primaryRepo.tag
set and primaryRepo.branch null, as on Hercules CI. Effects that only exist on
a tag, like the
github-releases
module of hercules-ci-effects, work unchanged.
Every onPush effect runs on every tag, so choose in the flake which effects
belong to branches and which to tags:
herculesCI = { primaryRepo, ... }: {
onPush.default.outputs.effects =
if primaryRepo.tag == null then
{ deploy = mkEffect { /* ... */ }; }
else
lib.optionalAttrs (lib.hasPrefix "v" primaryRepo.tag) {
release = mkEffect { /* ... */ };
};
};
How tag runs behave:
- A tag waits for a branch build of the tagged tree, so
git push origin main v1.0cannot leavemainwithout itsonPushrun. Retries back off for about 15 minutes. If no branch has built the tree by then, the tag builds the commit itself. - The effects run once per tag push, apart from the build’s own
onPushrun. Tagging a commit that the default branch already deployed still releases, and a second tag on the same commit runs them again and keeps its own result and log. isTagsecret conditions match, and ID tokens carryref: refs/tags/<tag>.- They show up on the build page with the event effects and are restarted the
same way (kind
tag:<tag>).
Limitations:
- Tag runs post no commit statuses.
- Tags are only seen through webhooks, not polled.
- An effect with
afteris skipped. Use a sharedlockto order tag effects. - Pushing a tag again while its run is going retries for a while, then fails.
Event effects
onPush effects run when a branch is built. onEvent effects react to what
happens around a build: a pull request turning green, a /command comment, a PR
closing, a build breaking. The typical use is tofu plan on a pull request with
the result posted as a comment, and tofu apply on merge. A full example is
examples/on-event/flake.nix. Hercules CI
ignores onEvent.
herculesCI = { ... }: {
onPush.default.outputs.effects.apply = mkEffect {
lock = "infra";
effectScript = "tofu apply -auto-approve";
};
onEvent.pull_request.plan = mkEffect {
when.permission = "write"; # pusher or PR author can write to the repo
lock = "infra"; # waits for a running apply
checkout = true; # PR head at $NIXBOT_EFFECT_CHECKOUT
effectScript = ''
tofu plan -no-color | nixbot-pr-comment --replace-marker plan
'';
};
onEvent.comment.apply = mkEffect {
when = { commands = [ "apply" ]; permission = "admin"; };
lock = "infra";
checkout = true;
effectScript = "tofu apply -auto-approve $NIXBOT_COMMAND_ARGS";
};
};
Definitions are always evaluated from the default branch, whatever pull
request the event is about. A pull request cannot change what runs by editing
onEvent, it only contributes data. This is unrelated to
effects_on_pull_requests in nixbot.toml, which is about a pull request’s own
onPush effects.
With checkout = true the PR head is cloned to /build/checkout. That
clone has no push credentials and its content is untrusted, because tofu plan
and similar tools execute code from it. Guard such effects with
when.permission. Secrets and the forge token are the same as for onPush.
Events
| kind | delivered when |
|---|---|
pull_request | a PR head was built green, also on reopen or label change |
comment | a PR comment whose first line is /command args |
pull_request_closed | a PR was closed or merged |
build_finished | any build finished, and each branch push that shared one |
Bot comments are ignored, and a /command for an effect that is still running
gets a note instead of a second run. Deliveries are queued in the database and
retried with backoff while the forge API is unavailable. Webhooks that arrive
while nixbot itself is down are not replayed.
The event reaches the script as JSON in $NIXBOT_EVENT_JSON (/run/event.json)
and, for the common fields, as NIXBOT_EVENT_KIND, NIXBOT_ACTOR,
NIXBOT_PR_NUMBER, NIXBOT_PR_HEAD, NIXBOT_COMMAND, NIXBOT_COMMAND_ARGS,
NIXBOT_BUILD_STATUS, NIXBOT_BUILD_URL. The JSON holds up to four keys:
actor: who caused it,{ "name": "github:alice", "permission": "write" }. It is absent for polled changes. Permissions come from the forge at delivery time.pullRequest:number,title,url,author(shaped likeactor),baseRef,headRef,headRev,labels,draft,isFork,merged.build:number,url,status,branch,rev. Forbuild_finishedit also haspreviousStatusandfailedAttrs, andrevis the pushed commit.command,args: forcomment.
Conditions
when restricts an effect to some deliveries. All keys must match. An effect
that does not match is listed as skipped with the reason.
when key | matches if |
|---|---|
permission = "write" | actor or PR author has at least read/write/admin (for comment, only the commenter) |
labels = [ "deploy" ] | the PR has all of these labels |
branches = [ "main" "release-*" ] | glob on the PR base branch, or the built branch |
commands = [ "plan" ] | comment: the /command used |
modified = [ "terraform/*" ] | a file the PR changes matches a glob (pull request events only) |
status = [ "failed" ] | status of the build in the payload |
transition = "broke" / "fixed" | build status changed against the previous finished build of that branch or PR |
Ordering
lock works as for onPush, so an event effect never overlaps a deploy holding
the same lock. The name may contain {pr}: lock = "preview-{pr}" serialises
per pull request. A newer delivery of the same kind for the same PR (for
comments: the same command) cancels not-yet-started effects of the previous one.
after is not supported.
Reporting back
Event effects post no commit statuses. mkEffect puts nixbot-pr-comment on
PATH instead, which comments on the pull request the event is about:
nixbot-pr-comment "text" or ... | nixbot-pr-comment. With
--replace-marker ID it edits the comment a previous run left with that marker.
Run locally it just prints the body.
Checks on every build
A pull request that breaks an effect should be red before it is merged, even
though its effects do not run there. Every build therefore evaluates onPush
and onEvent of the built commit and builds each effect’s dependencies without
running it, like hercules-ci-agent does for a runIf false effect. The results
appear as “Effect checks” on the build page and count towards the effects
forge status. Evaluation errors are shown there too, including a broken
onEvent on the default branch found while delivering an event.
Restarting
Event effects can be restarted from the build and run pages, with
nbo build restart N --effect comment/apply, or
POST /api/repos/.../builds/N/effects/restart?name=apply&kind=comment. A
running one is cancelled first. The stored payload is reused. A stuck effect can
be stopped without re-running it: nbo build cancel N --effect NAME or
POST .../effects/cancel?name=....
Testing locally
Describe the event with flags instead of pushing:
$ nbo effects list --event pull_request --pr 7 --permission write --label preview
$ nbo effects run --event comment --command apply --args "-target=null" \
--actor github:alice --permission admin --effect-checkout . apply
list prints every effect of that kind with the reason it would be skipped, so
a when can be tried without pushing. Flags cover what when looks at: --pr,
--actor, --permission, --author-permission, --label, --modified,
--command, --args, --build-status, --previous-build-status.
--payload FILE takes a payload nixbot recorded instead.
OIDC Authentication
nixbot supports generic OpenID Connect (OIDC) authentication, allowing you to use any OIDC-compliant identity provider (Keycloak, PocketID, Authentik, etc.) for user login.
NixOS Configuration
{
services.nixbot = {
oidc.enable = true;
# Provider-qualified identities: "oidc:<issuer-host>:<sub>" with the
# default mapping (see "User Identification" below).
admins = [
"oidc:keycloak.example.com:f3b0a6f0-1c2d-4e5f-9a8b-7c6d5e4f3a2b"
"oidc:keycloak.example.com:0f9e8d7c-6b5a-4f3e-2d1c-0b9a8f7e6d5c"
];
oidc = {
# Display name shown on login button
name = "My Identity Provider";
# OIDC discovery endpoint URL
discoveryUrl = "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration";
clientId = "nixbot";
clientSecretFile = "/run/secrets/nixbot-oidc-secret";
# Scopes to request (defaults shown)
scope = [ "openid" "email" "profile" ];
# Optional: include groups scope for group-based authorization
# scope = [ "openid" "email" "profile" "groups" ];
# Claim mapping (defaults shown)
mapping = {
username = "sub"; # Identity claim used for admin matching
groups = null; # Set to "groups" if using group sync
};
};
};
}
Set your OIDC provider’s callback URL to:
https://nixbot.example.com/auth/oidc/callback
Manual Configuration
For non-NixOS setups or local development, the service is configured via a JSON
file passed to nixbot --config:
{
"admins": [
"oidc:keycloak.example.com:f3b0a6f0-1c2d-4e5f-9a8b-7c6d5e4f3a2b",
"oidc:keycloak.example.com:0f9e8d7c-6b5a-4f3e-2d1c-0b9a8f7e6d5c"
],
"oidc": {
"name": "My Identity Provider",
"discovery_url": "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
"client_id": "nixbot",
"client_secret_file": "/path/to/client_secret",
"scope": ["openid", "email", "profile"],
"mapping": {
"username": "sub",
"groups": null
}
}
}
Provider Examples
Keycloak
oidc = {
name = "Keycloak";
discoveryUrl = "https://keycloak.example.com/realms/{realm-name}/.well-known/openid-configuration";
clientId = "nixbot";
clientSecretFile = "/run/secrets/keycloak-secret";
};
Authelia
See examples/oidc-authelia.nix for a complete configuration including the Authelia side: the client registration (Authelia stores only a digest of the client secret) and the provider’s HMAC secret and issuer key.
PocketID
oidc = {
name = "PocketID";
discoveryUrl = "https://id.example.com/.well-known/openid-configuration";
clientId = "nixbot";
clientSecretFile = "/run/secrets/pocketid-secret";
};
Testing with Mock Provider
For local development, you can use oidc-provider-mock:
# Create a client secret file
echo "abc" > /tmp/client_secret
# Start the mock OIDC provider
nix run nixpkgs#pipx -- run oidc-provider-mock \
--user-claims '{"sub": "alice", "email": "alice@example.com", "name": "Alice", "preferred_username": "alice123"}'
Then configure nixbot to use it (in the JSON config file):
"oidc": {
"name": "Mock",
"discovery_url": "http://localhost:9400/.well-known/openid-configuration",
"client_id": "123",
"client_secret_file": "/tmp/client_secret",
"scope": ["openid", "email", "profile"],
"mapping": {
"username": "sub",
"groups": null
}
}
User Identification
Users are identified by the claim configured in mapping.username, which
defaults to sub. This means:
- The
adminslist entries must be provider-qualified:oidc:<issuer-host>:<identity>where<issuer-host>is the issuer URL without thehttps://prefix and<identity>is the value of the configured claim subis the default because it is stable and unique per provider. A human-readable claim such aspreferred_usernamecan be configured instead, but only do so if your provider guarantees the claim is unique and not user-editable: a user who can change their ownpreferred_usernamecould otherwise take over another user’s admin or viewer entry
Private Repositories
OIDC users have no forge account, so nixbot cannot derive their repository
permissions from the forge and they only see public repositories by default.
privateRepoViewers grants visibility (read-only; build control stays with
admins):
services.nixbot.privateRepoViewers = {
# Repository keys: "forge:owner/repo", "forge:owner/*" or "*";
# the most specific key wins.
"*" = [
# Any authenticated user of this provider.
"oidc:auth.example.com:*"
];
"gitlab:acme/secret" = [
# Exact identity or OIDC groups claim.
"oidc:auth.example.com:alice"
"oidc:auth.example.com:group:auditors"
];
};
Group rules need the groups claim in the session: add the groups scope and set
mapping.groups = "groups".
The owner segment of a repository key is any namespace: a user, an organization,
or a nested GitLab group ("gitlab:org/subgroup/*").
Viewer rules apply equally to web sessions and personal API tokens. Users who log in with a forge account need no viewer rules: they get private-repo visibility automatically from their forge permissions.
Two caveats around group rules:
- Group membership is captured in the session at login, so revoking a group in the identity provider takes effect on the next login or session expiry, not immediately.
- Personal API tokens snapshot the creator’s groups: group rules apply to API requests with the groups the user had when the token was created, regardless of later membership changes.
Groups Support
To sync groups from your OIDC provider:
- Add the groups scope:
scope = [ "openid" "email" "profile" "groups" ]; - Set the groups claim mapping:
mapping.groups = "groups";
The claim name varies by provider - check your provider’s documentation.
Command-line client (nbo)
nbo lets you inspect and control builds on a nixbot instance from the
terminal: list builds, watch them finish, read failure logs, and restart or
cancel builds.
Run it directly:
nix run github:Mic92/nixbot#nixbot-cli -- --help
or add the nixbot-cli package to your profile or dev shell to get the nbo
command.
Connecting to a server
Point nbo at your instance with the NIXBOT_URL environment variable or a
config file:
export NIXBOT_URL=https://ci.example.org
# ~/.config/nixbot/hosts.toml
["https://ci.example.org"]
token = "bnix_..."
Read-only commands work without a token on public instances.
If you use more than one nixbot server, tell nbo which repositories belong to
which server. Add a remotes list to each server entry. When you run nbo
inside a checkout, it looks at the URL of the origin remote and picks the
first server whose pattern matches:
["https://ci.example.org"]
token = "bnix_..."
remotes = ["github.com/acme/*"]
["https://ci.other.org"]
remotes = ["git.other.org/*"]
Patterns are shell-style wildcards matched against the remote URL in the form
host/owner/repo, so both git@github.com:acme/widget.git and
https://github.com/acme/widget match github.com/acme/*.
You can also assign a single checkout to a server directly, which takes precedence over the patterns:
git config nixbot.url https://ci.example.org
When several settings are present, nbo uses the first of:
- the
NIXBOT_URLenvironment variable git config nixbot.url- a
remotespattern match - the only server in
hosts.toml, if there is just one
API tokens
Restarting or cancelling builds and enabling repositories require an API token.
Log in to the web UI of your instance and create one under Settings
(https://ci.example.org/settings), then either put it in hosts.toml as shown
above or export it:
export NIXBOT_TOKEN=bnix_...
Instead of storing the token in plain text you can have nbo fetch it from a
password manager such as pass or rbw on every run:
# ~/.config/nixbot/hosts.toml
["https://ci.example.org"]
token_command = "pass show ci.example.org/nixbot"
# or: token_command = "rbw get nixbot-ci"
nbo auth status shows which server and token are in use and whether the server
is reachable.
Repositories and builds
Inside a checkout the repository is inferred from the origin remote and the
build number from the HEAD commit; both can be given explicitly
(-R [forge/]owner/name, a build number as first argument).
nbo repo list
nbo repo enable github/acme/widget # instance admin or forge repo admin
nbo build list --branch main
nbo build view 412 # status, attribute summary, failed attributes
nbo build watch 412 # follow until it finishes, exit 1 on failure
nbo build watch 412 --attr treefmt --attr nixos-eve # wait only for these attributes
nbo build restart 412 --attr x86_64-linux.treefmt # a substring like "treefmt" works too
nbo build restart 412 --effects
nbo build cancel 412 # or --effects / --effect deploy for stuck effects
--json [fields] on list/view/log commands prints machine-readable output,
optionally projected to a comma-separated field list.
Logs
nbo log 412 # failure summary with log tails
nbo log 412 checks.x86_64-linux.nixos-test --tail 200
nbo log 412 /nix/store/...-zfs-2.2.4.drv # one derivation of the attribute
nbo log 412 checks.x86_64-linux.nixos-test --follow # stream while it runs
nbo log 412 --effect deploy # log of an effect of the build
nbo log 412 --effect deploy --follow # stream a running effect
nbo log --effect-run 9185 # any effect run by id
Attribute and effect arguments accept unambiguous substrings. For an effect, the
exit code is 1 if the run failed. --effect-run also reaches runs without a
build, such as scheduled ones. The id of a run is in nbo build view --json and
in the URL of its page on the web UI.
Effects
nbo effects runs and inspects a flake’s effects locally with nix
(no API token needed):
nbo effects list # effects with their after/lock metadata
nbo effects graph # dependency DAG as an ASCII tree
nbo effects run default.deploy # run one effect in the local sandbox
nbo effects run github:org/repo/branch#default.deploy
nbo effects run-scheduled github:org/repo#nightly flake-update
nbo effects list --event pull_request --pr 7 --permission write
nbo effects run --event comment --command apply --permission admin apply
For onEvent effects, --event KIND plus flags describing the event (--pr,
--actor, --permission, --label, --modified, --command, …) stand in
for a real delivery, and list then shows why each effect would be skipped.
Schedules
nbo schedule run starts a scheduled effect now, like the run-now button on the
repository’s effects page. It needs a token with admin rights on the repository.
nbo schedule run flake-update update # prints the effect run id
nbo schedule run flake-update update -f # stream its log, exit 1 if it fails
Exit codes
| code | meaning |
|---|---|
| 0 | success |
| 1 | the build or attribute failed |
| 2 | usage error / not found |
| 4 | authentication or permission failure |
Scripts and agents can also use the underlying HTTP API directly: every instance
documents it under /llms.txt and /api/openapi.json.
Workload identity for effects
nixbot can act as an OIDC issuer that mints short-lived ID tokens for running
effects. Relying parties (OpenBao/Vault, AWS/GCP/Azure federation,
niks3, …) verify the tokens against nixbot’s
JWKS and grant access based on the claims, so effects need no static deploy
secrets in effects.perRepoSecretFiles.
Enabled by default (services.nixbot.workloadIdentity); an effect only receives
tokens for audiences it declares.
Declaring audiences
Set idTokenAudiences on the effect derivation (an argument of this repo’s
effects-lib mkEffect, or a plain env attribute containing a JSON list):
effects-lib.mkEffect {
name = "deploy";
idTokenAudiences = [ "https://vault.example.com" ];
inputs = [ pkgs.openbao pkgs.openssh ];
effectScript = ''
export VAULT_ADDR=https://vault.example.com
# Authenticate to Vault with the nixbot-issued identity token.
vault write -field=token auth/jwt/login \
role=deploy jwt="$(nixbot-id-token https://vault.example.com)" > ~/.vault-token
# Trade it for a short-lived SSH certificate and deploy with it:
# no deploy key is stored in CI.
ssh-keygen -t ed25519 -N "" -f ./id_deploy
vault write -field=signed_key ssh/sign/deploy \
public_key=@./id_deploy.pub valid_principals=deploy > ./id_deploy-cert.pub
export NIX_SSHOPTS="-i ./id_deploy -o CertificateFile=./id_deploy-cert.pub"
nixos-rebuild switch --flake .#web01 \
--target-host deploy@web01.example.com \
--use-remote-sudo
'';
}
The matching Vault SSH CA setup is:
vault secrets enable ssh
vault write ssh/config/ca generate_signing_key=true
vault write ssh/roles/deploy \
key_type=ca allow_user_certificates=true \
allowed_users=deploy default_user=deploy ttl=10m
Inside the sandbox, nixbot-id-token <audience> prints a fresh ID token
(default lifetime 300 s). nixbot-id-token <audience> --json prints the raw
{"token": ..., "expires_at": ...} response, which is the format niks3’s
token-script credential expects. Requests for audiences not listed in
idTokenAudiences are rejected. The env variables it uses
(NIXBOT_ID_TOKEN_REQUEST_URL, NIXBOT_ID_TOKEN_REQUEST_TOKEN) are only
present for effects that declare at least one audience.
Note the opt-in scopes tokens per effect, not per person: anyone who can get an
effect with idTokenAudiences to run in the repository can obtain its tokens.
Restrict relying-party policies with the claims below (in particular
sub/ref), and remember effects only run for pushes and pull requests
according to the repository’s effects gating.
Claims
Common claims: iss (the nixbot URL), aud (one requested audience per token),
sub, event, forge, repository, repository_owner, effect, plus
iat/nbf/exp/jti.
| event | sub | extra claims |
|---|---|---|
| push | repo:<forge>:<owner>/<repo>:ref:refs/heads/<branch> | ref, sha, build_id |
| pull_request | repo:<forge>:<owner>/<repo>:pull_request | pr_number, base_ref, sha, build_id |
| schedule | repo:<forge>:<owner>/<repo>:schedule:<name> | schedule |
Pull-request tokens carry no ref claim: branch-based conditions (e.g. “only
refs/heads/main may deploy”) therefore never match a PR run.
Relying-party configuration
Discovery and JWKS documents are served unauthenticated at
<url>/.well-known/openid-configuration and <url>/.well-known/jwks.json.
Example niks3 provider:
[upload-oidc-providers.nixbot]
issuer = "https://ci.example.com"
audience = "https://cache.example.com"
bound_subject = "repo:github:acme/*:ref:refs/heads/main"
Example Vault/OpenBao JWT role for the deploy effect above (only pushes to main
of acme/widgets can log in; PR-triggered effects carry no ref claim and never
match):
vault write auth/jwt/config oidc_discovery_url=https://ci.example.com
vault write auth/jwt/role/deploy \
role_type=jwt user_claim=sub \
bound_audiences=https://vault.example.com \
bound_subject=repo:github:acme/widgets:ref:refs/heads/main \
token_policies=ssh-deploy token_ttl=5m
Keys
Tokens are RS256-signed. Without workloadIdentity.signingKeyFile, nixbot
generates the key in its state directory and rotates it every keyRotationDays
(default 30); the previous key stays in the JWKS so in-flight tokens keep
verifying. An operator-provided key is used as-is and never rotated — rotate it
by replacing the file and restarting nixbot, which invalidates tokens signed
with the old key.
Building in a remote store
services.nixbot.buildStore.url runs
nix build --store <url> --eval-store auto instead of building locally, for
example against a nix-grpc-store farm. Outputs stay in that store, so it cannot
be combined with uploaders.
The evaluation uses it too: nix-eval-jobs runs with NIX_REMOTE=<url> and
--eval-store daemon, so import-from-derivation builds run in the store while
the evaluated derivations stay in the local daemon.
With buildStore.oidcAudience set, nixbot writes an ID token for that audience
to a private file per build, refreshes it every two thirds of tokenTtl, and
points nix at it through the environment variable buildStore.credentialEnv
(default NIX_GRPC_TOKEN_FILE). The claims are the ones above with
effect = "build", so the store can match sub, ref or event.
Local Development
Running the test suite
nix develop
cd nixbot
python -m pytest nixbot/tests -q
The tests cover the full pipeline (webhook parsing, evaluation, scheduling,
building, status reporting, web frontend) against an ephemeral PostgreSQL
instance and real nix/git where available.
Running the service locally
nixbot is a single process configured by a JSON file:
# Start an ephemeral PostgreSQL
initdb -D /tmp/bb-pg
pg_ctl -D /tmp/bb-pg -o "-k /tmp/bb-pg -c listen_addresses=" start
createdb -h /tmp/bb-pg nixbot
cat > /tmp/nixbot.json <<EOF
{
"db_url": "postgresql://$(whoami)@/nixbot?host=/tmp/bb-pg",
"build_systems": ["x86_64-linux"],
"url": "http://localhost:8010/",
"state_dir": "/tmp/nixbot-state",
"pull_based": {
"repositories": {
"my-project": {
"name": "my-project",
"default_branch": "main",
"url": "https://github.com/example/my-project"
}
}
}
}
EOF
python -m nixbot.main --config /tmp/nixbot.json --log-format text
Access the web UI at http://localhost:8010. Pull-based repositories need no forge credentials, which makes them convenient for local hacking; GitHub/Gitea configuration works the same way as in the NixOS module, with secret paths pointing at plain local files.
VM integration test
The end-to-end NixOS test (fake GitHub + real Gitea) lives in
checks/nixbot.nix:
nix build .#checks.x86_64-linux.nixbot -L
For interactive debugging:
nix build .#checks.x86_64-linux.nixbot.driverInteractive
./result/bin/nixos-test-driver
Add breakpoint() in the test script to pause execution.
Code quality
nix develop -c flake-fmt # treefmt: ruff format, ruff check, mypy, nixfmt
SQL queries (sqlc)
SQL statements live in nixbot/nixbot/queries/*.sql and are compiled to typed
asyncpg query functions in nixbot/nixbot/db_gen/ (generated code, do not edit)
by sqlc with the
sqlc-gen-better-python
plugin; the schema is read from nixbot/nixbot/migrations/. After changing a
query or migration, regenerate and commit the output:
nix develop -c sqlc generate
The dev shell’s sqlc is wrapped (nix/sqlc.nix) to use the same nix-pinned
plugin as CI and works offline; when bumping the plugin, update the URL/checksum
in both sqlc.yaml and nix/sqlc.nix.
CI verifies freshness via nix build .#checks.x86_64-linux.sqlc-generated.
Dynamically assembled SQL (e.g. the build-list filters in web/queries.py) and
the migration runner stay on raw asyncpg.
Migrating from buildbot-nix
Nixbot is a rewrite of buildbot-nix, its spiritual ancestor, and no longer runs on top of Buildbot. The old master/worker pair is now a single asyncio service that does everything itself: forge webhooks, evaluation with nix-eval-jobs, builds through the local nix daemon, commit statuses, hercules-ci-style effects, and its own web UI with a JSON API, live logs over SSE, and Prometheus metrics.
This guide covers moving an existing buildbot-nix deployment to nixbot.
What you need to do when upgrading
NixOS module. Import nixosModules.nixbot; options live under
services.nixbot. Your existing services.buildbot-nix.master.* config keeps
working: options rename to services.nixbot.* automatically and print
deprecation warnings. nixosModules.buildbot-master and
nixosModules.buildbot-worker are aliases for the new module. Options that have
no equivalent anymore (workers, oauth2-proxy mode, dbUrl, the Gitea webhook
secret) fail the build with a hint on what to do instead.
Workers are gone. Delete workersFile, worker passwords and localWorkers.
Builds go through the nix daemon and scale with ordinary nix remote builders.
Database. PostgreSQL only, with a fresh schema and plain SQL migrations.
Build history does not carry over. By default the module provisions a local
PostgreSQL over the unix socket; for a remote database set database.url or
database.urlFile.
Authentication. httpbasicauth and the oauth2-proxy
accessMode.fullyPrivate setup are gone. The built-in GitHub/Gitea/OIDC login
covers the same need: private repositories are hidden from anyone not authorized
to see them. GitHub token mode is also gone; use a GitHub App.
authBackend is removed. Enable forges explicitly (github.enable,
gitea.enable, oidc.enable); every enabled forge with OAuth credentials
configured offers a login, and several can be active at once.
admins entries must be provider-qualified: github:Mic92, not Mic92.
Unqualified entries never match and only log a warning.
OAuth callback URLs change: update your GitHub App / Gitea application to
https://<domain>/auth/<provider>/callback (buildbot used /auth/login), e.g.
https://buildbot.example.com/auth/github/callback.
Commit statuses / check runs. GitHub now receives Check Runs instead of
commit statuses; Gitea/GitLab keep commit statuses. Names default to the
nixbot/ prefix (nixbot/nix-eval, nixbot/nix-build ...). Set
services.nixbot.statusContextPrefix = "buildbot" to keep the buildbot-era
names so existing branch protection rules keep working (GitHub matches check-run
names and commit-status contexts in the same required-checks namespace);
otherwise update the required status checks on every repository. Grant the
GitHub App the Checks: read & write permission and subscribe it to the
Check run / Check suite events.
Webhooks. The old GitHub endpoint (/change_hook/github) still works as an
alias. /change_hook/gitea is gone: legacy Gitea hooks carry old buildbot
secrets that cannot match the per-repository secrets this service generates, and
the service re-registers Gitea hooks against /webhooks/gitea automatically.
However, per-repository GitHub webhooks are no longer created: events arrive
through the App-level webhook. Enable the webhook on your GitHub App (Active,
URL https://<domain>/webhooks/github, secret matching webhookSecretFile,
events push, pull_request, check_run, check_suite); see
docs/GITHUB.md. Subscribing to the pull_request event
requires the “Pull requests: Read-only” repository permission. Adding a
permission must be accepted on every installation of the app (your user account
and each organization) under Settings → GitHub Apps → Configure. The service
logs a warning at startup if the app is misconfigured.
Gitea webhooks now register at https://<domain>/webhooks/gitea with an
auto-generated per-repository secret stored in the database —
gitea.webhookSecretFile is gone. Existing hooks are re-synced in place,
leftover buildbot-era hooks pointing at this instance are removed. Hooks
subscribe to push, pull_request and pull_request_sync.
Project enablement. Which repositories get built is now a per-project toggle
in the web UI (admins only). topic is reduced to a one-shot import: on first
startup with an empty database, repositories carrying the topic are enabled;
afterwards it is ignored. userAllowlist/repoAllowlist remain a hard boundary
at discovery time.
Per-repository config. The file is now called nixbot.toml; the legacy
buildbot-nix.toml name keeps working with unchanged contents.
Post-build steps. interpolate placeholders still work. Properties: attr,
out_path, drv_path, system, project, branch, revision, pr_number,
default_branch. Two changes:
- PR builds no longer run under a
refs/pull/N/mergebranch — use%(prop:pr_number)sinstead of parsing the branch name. %(secret:NAME)sreads systemd credentials of thenixbotunit, so moveLoadCredentialentries fromsystemd.services.buildbot-mastertosystemd.services.nixbot.
Cache uploads. services.nixbot.cachix/niks3 now push every derivation
built locally, not just each attribute’s output closure. Custom nix copy or
attic push post-build steps belong in services.nixbot.uploaders to get the
same behaviour (attic needs pathsVia = "stdin"). The niks3 module runs one
long-lived niks3 push --stdin and needs niks3 >= 1.11.
Buildbot customizations. Anything that reached into Buildbot itself —
services.buildbot-master.extraConfig, the manhole, pythonPackages — has no
equivalent.
API. The JSON API moves to /api/*, with an OpenAPI schema at
/openapi.json.
What you get compared to buildbot-nix
- Builds are keyed by the post-merge tree hash: identical trees across branches and PRs reuse results instead of rebuilding.
- Crash recovery: after a restart, unfinished builds resume from their stored eval results without re-evaluating.
- Evaluation runs in a bwrap sandbox with a kernel-enforced memory cap (delegated cgroup v2 subtree).
- Per-user API tokens for scripted access.
- GitLab support (
services.nixbot.gitlab): token-based, with per-repository webhooks and commit statuses. See docs/GITLAB.md. - Effects show up on the build page with status, live log and failure excerpt.
- Effects parity with hercules-ci-agent:
secretsMapselection with per-secret conditions,GitTokenreferences, the current-task state API (getStateFile/putStateFile), per-effect mounts (effects.mountables), virtual uid/gid, a disk-backed/build, and a private untrusted nix daemon per effect (effects.extraNixOptions). Effects withoutsecretsMapkeep receiving the whole secrets file.