Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kennel

Kennel is the deployment platform for ScottyLabs. When you push code to a repository, kennel builds it with Nix, deploys it, and routes traffic to it.

What it does

  • Builds your project with Nix when you push to any branch
  • Deploys services as systemd units and static sites via Caddy
  • Provisions per-deployment databases (PostgreSQL), caches (Valkey), and object storage (Garage)
  • Resolves secrets from OpenBao via secretspec
  • Generates HTTPS URLs for every deployment, including PR previews
  • Tears down deployments and their resources when branches are deleted or PRs are closed

How it works

Your project’s devenv.nix declares what it needs to run: processes, databases, secrets, static sites. Kennel evaluates this configuration, builds the Nix packages, and deploys everything with isolated resources per branch.

Every deployment gets a URL at {project}-{branch}.scottylabs.net. Production deployments can also have custom domains.

Getting started

See the deploying a project guide.

Deploying a Project

This guide walks through setting up a ScottyLabs project for deployment with kennel.

Prerequisites

  • A repository in the ScottyLabs Forgejo organization
  • devenv and direnv installed locally
  • A flake.nix and devenv.nix in your project root

1. Import the shared module

Add the ScottyLabs devenv input to your devenv.yaml:

secretspec:
  enable: true
  provider: vault://secrets2.scottylabs.org/secret
  profile: dev

inputs:
  scottylabs:
    url: git+https://codeberg.org/ScottyLabs/devenv
  rust-overlay:
    url: github:oxalica/rust-overlay
    inputs:
      nixpkgs:
        follows: nixpkgs
  treefmt-nix:
    url: github:numtide/treefmt-nix
  git-hooks:
    url: github:cachix/git-hooks.nix
    inputs:
      nixpkgs:
        follows: nixpkgs

Import it in your devenv.nix:

{ pkgs, config, inputs, ... }:
{
  imports = [ inputs.scottylabs.devenvModules.default ];

  scottylabs = {
    enable = true;
    project.name = "my-project";
  };
}

The secretspec block resolves your project’s secrets from OpenBao into the shell. See the Secrets guide to declare and manage them.

2. Set up direnv and .gitignore

Create an .envrc to automatically activate the devenv environment when you enter the project directory:

eval "$(devenv direnvrc)"
use devenv

Then allow it:

direnv allow

Add a .gitignore for generated and local-only files:

# Nix / devenv
.devenv/
.devenv.flake.nix
.pre-commit-config.yaml
result
result-*

# AI
.mcp.json
.claude

# direnv
.direnv/

# Rust
target/
.cargo/

# Secrets
.env

# OS
.DS_Store
rustc-ice-*.txt

Add any project-specific entries as needed (e.g., sites/docs/book/ for mdbook output, node_modules/ for JS projects).

3. Declare what to deploy

Add kennel options to your devenv.nix to tell kennel what your project produces.

For a backend service:

scottylabs.kennel.services.api = {
  customDomain = "api.my-project.scottylabs.org";
};

processes.api = {
  exec = "${pkgs.my-project}/bin/api";
  ready.http.get = { port = 8080; path = "/health"; };
};

If your service needs OIDC, declare the redirect paths and kennel will provision and reconcile a Keycloak client for you on every deploy:

scottylabs.kennel.services.api = {
  customDomain = "api.my-project.scottylabs.org";
  oidc.redirectPaths = [ "/oauth2/callback" ];
};

Kennel creates a confidential my-project client with redirect URIs covering both the kennel-default URL (my-project-main.scottylabs.net) and the custom domain, plus a my-project-staging client for staging deployments. PR previews are added to the staging client on PR open and removed on PR close.

Your service receives the credentials via OIDC_CLIENT_ID and OIDC_CLIENT_SECRET env vars, declared in your secretspec.toml:

[profiles.prod]
OIDC_CLIENT_ID = { description = "Keycloak OIDC client ID" }
OIDC_CLIENT_SECRET = { description = "Keycloak OIDC client secret" }

For a static site:

scottylabs.kennel.sites.docs = {
  spa = false;
};

The site name (docs) must match a package in your flake.nix outputs. Kennel builds it with nix build .#packages.{system}.docs.

Runtime environment

Kennel injects these variables into every backend service it deploys:

  • PORT: the port your service must bind to. Kennel allocates it and routes the public domain to it through Caddy, so read it at startup instead of hardcoding a port.
  • COMMIT_HASH: the full Git commit SHA of the running build.

Resolved secrets from your secretspec.toml are injected alongside these.

4. Enable infrastructure

If your project needs a database:

scottylabs.postgres.enable = true;

This gives you a local PostgreSQL instance in development and a provisioned per-deployment database in production. Your app reads DATABASE_URL from the environment in both cases.

5. Enable kennel in governance

In the ScottyLabs governance repository, set the kennel flag for your project. Governance provisions the webhook that connects your repository to kennel.

6. Push

Push to any branch. Kennel receives the webhook, builds your project, and deploys it. Your deployment will be available at:

  • my-project-main.scottylabs.net for the main branch
  • my-project-pr-42.scottylabs.net for PR #42
  • my-project-feature-x.scottylabs.net for a feature branch

Flake packages

Your flake.nix must expose packages that kennel can build. The package names must match the keys in scottylabs.kennel.services and scottylabs.kennel.sites. For Rust projects, the supported pattern is crane:

inputs = {
  nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
  crane.url = "github:ipetkov/crane";
};

outputs = { self, nixpkgs, crane, ... }:
  let
    forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
  in {
    packages = forAllSystems (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
        craneLib = crane.mkLib pkgs;
        commonArgs = {
          src = craneLib.cleanCargoSource ./.;
          strictDeps = true;
        };
        cargoArtifacts = craneLib.buildDepsOnly commonArgs;
      in {
        api = craneLib.buildPackage (commonArgs // {
          inherit cargoArtifacts;
          pname = "api";
          cargoExtraArgs = "-p my-project";
          doCheck = false;
        });
        docs = pkgs.stdenv.mkDerivation { ... };
        default = self.packages.${system}.api;
      }
    );
  };

Kennel builds each package with nix build .#packages.{system}.{name}.

Secrets

Kennel resolves secrets from OpenBao via secretspec. Secrets are injected as environment variables and never written to disk or stored in the database.

Declaring secrets

Create a secretspec.toml in your project root. The [project] table requires a name and revision = "1.0", and secrets are declared per profile:

[project]
name = "my-project"
revision = "1.0"

[profiles.default]
JWT_SECRET = { description = "JWT signing key", required = true }
STRIPE_KEY = { description = "Stripe API key", required = true }

# Declare all profiles, even if you only use default
# default secrets can only be substituted into existing profiles
[profiles.dev]

[profiles.prod]

[profiles.preview]
STRIPE_KEY = { description = "Stripe test key", required = false }

[profiles.default] holds the secrets shared across every profile; named profiles inherit from it and may override individual entries, for example making STRIPE_KEY optional in preview. Declare a [profiles.<name>] header for dev (used locally) and for every environment you deploy to (see the branch-to-profile mapping); a section may be left empty to inherit default unchanged.

Configuring the provider

Point secretspec at OpenBao in your devenv.yaml. Copy this block once per project:

secretspec:
  enable: true
  provider: vault://secrets2.scottylabs.org/secret
  profile: dev

enable turns on resolution, provider is the default backend for every secret, and profile selects which profile to load locally (kennel chooses the profile per branch when it deploys). The shared ScottyLabs config (scottylabs.enable = true) supplies the bao and secretspec CLIs, sets BAO_ADDR, and exports every resolved secret into your shell.

Per-developer secrets

Some secrets differ per developer and cannot be shared, for example each person’s own DISCORD_TOKEN while developing a bot. Source those from a gitignored .env instead of OpenBao: define a local alias in a [providers] table and give the secret a provider chain in the dev profile.

[providers]
local = "dotenv://.env"

[profiles.prod]
DISCORD_TOKEN = { description = "Discord bot token" }

[profiles.dev]
DISCORD_TOKEN = { providers = ["local"] }

prod resolves DISCORD_TOKEN from OpenBao (the default provider) while dev reads it from your .env. Chains are tried in order, so providers = ["vault", "local"] would try OpenBao first and fall back to .env; every alias you name must be defined in this committed [providers] table. Keep .env gitignored.

Local development

Authenticate to OpenBao once:

bao login -method=oidc

After that, your secrets are resolved and exported into the shell automatically each time direnv loads the environment (when you cd into the project).

Managing secrets

Set a secret for the default (dev) profile:

secretspec set JWT_SECRET

Set a secret for a specific profile:

secretspec set -P prod STRIPE_KEY
secretspec set -P preview STRIPE_KEY

Verify all required secrets are present:

secretspec check
secretspec check -P prod

Production

Kennel authenticates to OpenBao with a service token provided via VAULT_TOKEN in its environment file. It resolves secrets for each deployment using the profile matching the branch:

BranchProfile
mainprod
stagingstaging
devdev
pr-*preview

If a required secret cannot be resolved, the deployment fails. Deployed services also receive PORT and COMMIT_HASH (see Deploying a Project).

PR Deployments

Every pull request gets its own isolated deployment with its own database, cache, and URL.

Lifecycle

  1. PR opened: kennel builds the PR branch, provisions resources, and deploys. The deployment is available at {project}-pr-{number}.scottylabs.net.
  2. Push to PR: kennel rebuilds. Unchanged services (same nix store path) are skipped. Changed services are redeployed.
  3. PR closed: kennel tears down all deployments for the branch, deprovisions resources (drops the database, flushes the cache, deletes the storage bucket), and removes the Caddy route.

Status comments

After each successful PR deploy, kennel posts (and on subsequent deploys edits) a sticky comment on the pull request listing every service URL for that branch. When the PR closes and deployments are torn down, the comment is updated to reflect the teardown. Comments are identified by an HTML marker in the body so kennel can update the same comment instead of creating duplicates.

This requires the operator to configure services.kennel.forgejo.apiTokenFile with a Forgejo API token that has the write:issue scope. See the NixOS Module reference.

Resource isolation

Each PR deployment gets:

  • Its own PostgreSQL database (kennel_{project}_{branch})
  • Its own Valkey DB number
  • Its own Garage S3 bucket and API key

Connection strings are injected as environment variables. Your application code does not need to know whether it is running in production or a PR preview.

OIDC redirect URIs

For services declaring oidc.redirectPaths, kennel adds the PR-preview URL ({project}-pr-{number}.scottylabs.net) to the staging Keycloak client’s valid_redirect_uris on PR open, and removes it on PR close. PR previews share the same OIDC client (and credentials) as the staging branch, so the same client secret applies.

Expiry

PR deployments that have had no activity for 7 days are hibernated: the process is stopped but the database is kept. After 30 days, the deployment and its resources are fully torn down.

URLs

PR URLs follow the flat scheme {project}-pr-{number}.scottylabs.net, covered by a single wildcard DNS record. No per-deployment DNS management is needed.

Architecture

Kennel runs as a single binary with four main responsibilities: receiving webhooks, building Nix packages, deploying them, and reconciling desired state against actual state.

Request flow

Git push -> Webhook -> Build (nix) -> Deploy (systemd + Caddy) -> Live
  1. Forgejo sends a webhook to kennel’s /webhook endpoint.
  2. Kennel parses the repository name from the payload, verifies the HMAC signature, and creates a build record.
  3. The build worker clones the repo, runs devenv build scottylabs.kennel.config to discover declared services and sites, then runs nix build for each package. Every subprocess (git, devenv, nix, cachix) streams its stdout and stderr line-by-line through structured tracing, so the build log shows up in journald (and downstream Loki) labelled by build_id and phase. The full per-phase log is also persisted to the builds.log column for later retrieval.
  4. The reconciler picks up the completed build, provisions resources (database, cache, storage), resolves secrets from OpenBao, starts a systemd transient unit for services, and adds a Caddy route for each deployment.
  5. Caddy serves traffic over HTTPS with on-demand TLS.

Delegation

Kennel delegates process supervision to systemd and HTTP routing to Caddy, keeping the core focused on build orchestration and resource provisioning.

Systemd transient units are created via D-Bus using the zbus crate. Units are placed in the kennel.slice cgroup for aggregate accounting, with CPUAccounting, MemoryAccounting, IOAccounting, and TasksAccounting enabled so per-deployment resource usage is queryable from cgroup metrics by anything scraping systemd_unit_* or systemd_slice_* (e.g. prometheus-systemd-exporter filtered to kennel-* units). Transient units survive kennel crashes since they are independent of the kennel process.

Caddy routes are managed via the admin API. Each deployment gets a route identified by @id for individual add/remove operations. Caddy handles TLS certificate provisioning, HTTP/3, static file serving, reverse proxying, and SPA fallback.

HTTP API

Kennel exposes a small set of HTTP endpoints alongside the webhook receiver:

MethodPathPurpose
POST/webhookGit push and pull request events from Forgejo, HMAC-verified.
GET/metricsPrometheus exposition: kennel_builds{status=...}, kennel_deployments, kennel_projects gauges.
GET/builds/:id/logPlaintext concatenation of every subprocess’s output captured during the build, with === phase: <name> === separators.
GET/deployments/:id/logsjournald output for the deployment’s systemd unit. Query params: ?follow=true for chunked live tail, ?lines=N&since=....
GET/deployments/:id/healthJSON: active, active_state, sub_state, active_enter_usec, n_restarts from the unit’s D-Bus properties.
GET/internal/caddy/check-domainUsed by Caddy’s on-demand TLS to validate a hostname is a registered deployment before acquiring a cert.

All endpoints other than /webhook are unauthenticated and read-only. Caddy’s services.kennel.domain virtualhost reverse-proxies these to the kennel API server, which only listens on localhost; the trust boundary is the host firewall plus tailnet, not application-level auth.

Routes are mounted in http.rs; per-resource handlers live under handlers/.

Reconciliation

A single reconciliation loop handles all deployment convergence. It runs on startup, when signaled by a webhook or build completion, and on a periodic 30-second timer.

The reconciler compares desired state (deployment rows in the database) against actual state (systemd units and Caddy routes) and converges:

  • A deployment row with no running unit gets its unit started.
  • A running unit with no deployment row gets stopped.
  • All Caddy routes are re-added on each pass since Caddy config is ephemeral.

There are no intermediate deployment states like “deploying” or “tearing down” that could get stuck. A deployment either has a row in the database or it doesn’t, which eliminates stuck-state bugs by construction.

State

Kennel stores state in SQLite with three tables:

  • projects – registered repositories with webhook secrets
  • builds – build queue and history (queued, building, built, done, failed, cancelled), plus the captured per-phase log of subprocess output
  • deployments – active deployments with store paths, domains, unit names, and ports

Runtime process state (running, stopped, failed) is owned by systemd and queried via D-Bus. Routing state is owned by Caddy and queried via the admin API. Kennel’s database only tracks intent plus the historical build artifacts (logs) systemd doesn’t keep.

OIDC client reconciliation

For services declaring oidc.redirectPaths, kennel keeps a pair of Keycloak confidential clients in sync per project: {slug} for prod and {slug}-staging for staging. On each deploy of a service with OIDC, kennel calls Keycloak’s admin API to ensure the client exists with the correct valid_redirect_uris (kennel-default URL + customDomain if set for prod; kennel-default URL for staging). PR-preview URLs are added to the staging client on PR open and removed on PR close.

Kennel authenticates as a service-account client (services.kennel.keycloak.adminClientId) holding the realm-management/manage-clients role. The client itself is provisioned in tofu under infrastructure/tofu/identity/kennel.tf; its secret is stored at secret/data/infra/kennel-keycloak-admin and rendered to disk by bao-agent.

Reconciliation is fire-and-forget: a failure logs a warning but does not block the deploy. The next deploy retries.

Crate structure

  • kennel – main binary. HTTP router lives in src/http.rs, request handlers under src/handlers/{webhook,metrics,builds,deployments,caddy}.rs. Build orchestration in src/build.rs, deploy in src/deploy.rs, reconciliation in src/reconcile.rs. Systemd, Caddy, Keycloak, and OpenBao clients each have their own module.
  • kennel-config – shared types, constants, environment enum
  • kennel-provision – resource provisioning trait and implementations (PostgreSQL, Valkey, Garage)
  • entity – SeaORM generated entities
  • migration – SQLite schema migrations

devenv Options

These options are provided by the shared ScottyLabs devenv module. Import it in your devenv.nix:

imports = [ inputs.scottylabs.devenvModules.default ];

scottylabs

scottylabs.enable

Enable the shared ScottyLabs development configuration. Required for all other scottylabs.* options to take effect.

Type: bool, default: false

scottylabs.project.name

Project name. Used for database naming, log filtering, and secrets path resolution.

Type: str, required when scottylabs.enable = true

scottylabs.conventionalCommits.enable

Enforce Conventional Commits on git commit via the commitizen git hook. Commit messages that do not match the conventional format are rejected at commit time.

Type: bool, default: true

scottylabs.cachix

scottylabs.cachix.push

Push builds to the scottylabs cachix cache. Each developer must run this once, from inside any ScottyLabs devenv shell (after bao login -method=oidc):

cachix authtoken $(bao kv get -field=CACHIX_AUTH_TOKEN secret/shared/cachix)

The cache is always pulled when scottylabs.enable = true, regardless of this option.

Type: bool, default: true

scottylabs.rust

scottylabs.rust.enable

Enable the Rust development toolchain. Configures nightly Rust with cranelift (fast debug-mode codegen), clippy, rustfmt, and the wild/lld linker.

Type: bool, default: false

scottylabs.rust.cranelift.excludePackages

Crate names forced to the LLVM backend instead of cranelift. Some crates use features that cranelift does not support (FFI symbol emission, linker sections).

Type: listOf str, default: [ "aws-lc-sys" "aws-lc-rs" "rustls" ]

scottylabs.deno

scottylabs.deno.enable

Enable the Deno/JavaScript development toolchain. Adds Deno, oxlint (with --deny all), oxfmt, and tsgolint on PATH for oxlint --type-aware.

Type: bool, default: false

scottylabs.deno.react.enable

Add the react and jsx-a11y plugins to oxlint.

Type: bool, default: false

scottylabs.deno.svelte.enable

Add the svelte-check pre-commit hook.

Type: bool, default: false

scottylabs.postgres

scottylabs.postgres.enable

Enable a local PostgreSQL 18 instance with Unix socket access. Creates an initial database named after scottylabs.project.name and exports DATABASE_URL into the shell environment.

Type: bool, default: false

scottylabs.postgres.extensions

PostgreSQL extensions as a function of the extensions set.

Type: function, default: e: [ e.pg_uuidv7 ]

scottylabs.sqlite

scottylabs.sqlite.enable

Enable SQLite for local development. Adds the sqlite package and exports DATABASE_PATH pointing to a database file in the devenv state directory.

Type: bool, default: false

scottylabs.valkey

scottylabs.valkey.enable

Enable a local Valkey instance for development. Layers services.redis.package = pkgs.valkey under the hood, so the upstream services.redis devenv module drives the process while the binary is the wire-compatible Valkey fork. Adds pkgs.valkey to the shell so valkey-cli is on the path.

Type: bool, default: false

scottylabs.secrets

When scottylabs.enable = true, the openbao (bao) and secretspec CLIs are added to the shell, BAO_ADDR is set for OpenBao authentication, and every secret secretspec resolves is exported into the shell environment. Resolution is enabled per project through the secretspec block in devenv.yaml (see Secrets).

scottylabs.keycloak

scottylabs.keycloak.enable

Enable a local Keycloak instance for development. Bootstraps the scottylabs realm with a confidential OIDC client matching scottylabs.project.name. The client secret is read from [profiles.dev].OIDC_CLIENT_SECRET.default in the project’s secretspec.toml so the dev realm and the secretspec contract stay in sync.

Type: bool, default: false

scottylabs.keycloak.port

HTTP port the local Keycloak listens on (bound to 127.0.0.1).

Type: port, default: 8088

scottylabs.keycloak.devClient.redirectUris

Permitted redirect URIs for the dev OIDC client.

Type: listOf str, default: [ "http://localhost:*/*" "http://127.0.0.1:*/*" ]

scottylabs.kennel

scottylabs.kennel.services

Backend services deployed by kennel. Each key must match a devenv process name. Kennel builds the corresponding flake package and deploys it as a systemd transient unit.

Type: attrsOf submodule

Each service accepts:

  • customDomain (nullOr str, default: null) – custom domain for this service
  • oidc (nullOr submodule, default: null) – when set, kennel reconciles a Keycloak prod and staging client for the project on every deploy. Accepts:
    • redirectPaths (listOf str) – redirect URI paths (e.g. "/oauth2/callback"). Hosts are derived from kennel’s URL pattern: https://{slug}-main.scottylabs.net{path} for prod (plus customDomain if set) and https://{slug}-staging.scottylabs.net{path} for staging. PR-preview URLs ({slug}-pr-{N}.scottylabs.net) are added to the staging client on PR open and removed on PR close

scottylabs.kennel.sites

Static sites deployed by kennel. Each key names a site. Kennel builds the corresponding flake package and serves it via Caddy’s file server.

Type: attrsOf submodule

Each site accepts:

  • spa (bool, default: false) – serve index.html for all routes
  • customDomain (nullOr str, default: null) – custom domain for this site

scottylabs.kennel.config

Read-only. The generated kennel.json derivation that the kennel builder evaluates at build time. You do not set this directly.

Type: package

scottylabs.claude

scottylabs.claude.enable

Enable Claude Code integration. Generates the .mcp.json configuration with the devenv MCP server.

Type: bool, default: true

NixOS Module

The kennel NixOS module configures the kennel service, Caddy, systemd integration, and resource provisioning on a NixOS host.

{ kennel, ... }:
{
  imports = [ kennel.nixosModules.default ];

  services.kennel = {
    enable = true;
    package = kennel.packages.x86_64-linux.kennel;
    devenvPackage = kennel.packages.x86_64-linux.devenv;
    webhookSecretFile = config.age.secrets.kennel-webhook.path;
    environmentFile = config.age.secrets.kennel.path;

    domains = {
      ephemeral = "scottylabs.net";
      cloudflare.zones."scottylabs.org" = "<zone-id>";
    };

    resources.postgres = {
      enable = true;
      socketDir = "/run/postgresql";
    };

    secrets = {
      enable = true;
      vaultEndpoint = "https://secrets2.scottylabs.org";
    };
  };
}

Options

services.kennel.enable

Enable the kennel deployment platform.

Type: bool, default: false

services.kennel.package

The kennel package to use.

Type: package

services.kennel.devenvPackage

The devenv package. The build worker uses devenv build to evaluate project kennel configs from their devenv.nix.

Type: package

services.kennel.environmentFile

Path to an environment file containing secrets like VAULT_TOKEN, CACHIX_AUTH_TOKEN, and GARAGE_ADMIN_TOKEN. Loaded by systemd before the service starts.

Type: nullOr path, default: null

services.kennel.api.host / services.kennel.api.port

API server bind address and port.

Type: str / port, defaults: "0.0.0.0" / 3000

services.kennel.webhookSecretFile

Path to a file containing the HMAC secret used to verify all incoming webhooks. This is a single secret shared across all projects, provisioned by governance.

Type: path

services.kennel.domains.ephemeral

Base domain for auto-generated deployment URLs. A wildcard DNS record should point *.{domain} to the kennel server.

Type: str, default: "scottylabs.net"

services.kennel.domains.cloudflare.zones

Map of domain names to Cloudflare zone IDs. When this is non-empty, publicIp is set, and the CLOUDFLARE_API_TOKEN env var is provided (typically via the environmentFile secret), kennel automatically manages A records for any custom domain whose suffix matches one of the configured zones. The most specific zone wins for nested domains.

The token must have Zone:DNS:Edit permission on the zones listed.

Records are upserted on deploy and on each reconciliation pass (so they self-heal if pruned externally). Records are deleted only when kennel tears the deployment down, which happens in three cases:

  1. The branch backing the deployment is deleted from the source repo (push event with deleted=true on that ref).
  2. The deployment is associated with a pull request and that pull request is closed.
  3. The deployment is on a dev or preview branch and exceeds DEPLOYMENT_EXPIRY_DAYS since its last update during a reconciliation pass.

Production deployments are not subject to expiry, so a record for a production custom domain stays in place until the project’s main branch is deleted or the deployment row is removed manually.

Type: attrsOf str, default: {}

services.kennel.domains.cloudflare.publicIp

Public IPv4 used as the content of the A records that kennel creates for custom domains. Required to enable DNS automation.

Type: nullOr str, default: null

services.kennel.domain

Public domain for the kennel API and webhook endpoint. The module configures a Caddy virtualhost with automatic TLS for this domain, reverse-proxying to the API server.

Type: str, default: "kennel.scottylabs.org"

services.kennel.caddy.adminUrl

Caddy admin API URL.

Type: str, default: "http://localhost:2019"

services.kennel.builder.maxConcurrentBuilds

Maximum number of concurrent nix builds.

Type: int, default: 2

services.kennel.builder.workDir

Build working directory.

Type: path, default: "/var/lib/kennel/builds"

services.kennel.builder.cachix.enable / services.kennel.builder.cachix.cacheName

Enable pushing build artifacts to a Cachix binary cache.

services.kennel.resources.postgres

Enable PostgreSQL resource provisioning. Kennel creates a database per deployment using the specified socket directory for peer authentication.

  • enable (bool, default: false)
  • socketDir (path, default: "/run/postgresql")

services.kennel.resources.valkey

Enable Valkey resource provisioning. Kennel allocates a DB number per deployment from the shared instance.

  • enable (bool, default: false)
  • socketPath (path, default: "/run/valkey/valkey.sock")

services.kennel.resources.garage

Enable Garage S3 resource provisioning. Kennel creates a bucket and API key per deployment. Requires GARAGE_ADMIN_TOKEN in the environment file.

  • enable (bool, default: false)
  • adminEndpoint (str, default: "http://localhost:3903")
  • s3Endpoint (str, default: "http://localhost:3900")

services.kennel.secrets

Enable secretspec/OpenBao secret resolution at deploy time.

  • enable (bool, default: false)
  • vaultEndpoint (str, default: "https://secrets2.scottylabs.org")

services.kennel.forgejo

Forgejo API access for posting deployment status comments on pull requests. Required.

  • apiUrl (str, default: "https://codeberg.org/api/v1") – Forgejo API base URL
  • apiTokenFile (path, required) – path to a file containing an API token with the write:issue scope. Kennel uses this to post and update a sticky comment on each PR listing its deployment URLs, and to mark the comment torn down when the PR is closed.

services.kennel.keycloak

Keycloak admin access for OIDC client reconciliation. When url is set, kennel manages a confidential client per project (named after the project slug) plus a {slug}-staging client, keeping their valid_redirect_uris in sync with each service’s oidc.redirectPaths declared in scottylabs.kennel.services.<name>.oidc. PR-preview URLs are added on PR open and removed on PR close.

  • url (nullOr str, default: null) – Keycloak server URL. Setting this enables reconciliation
  • realm (str, default: "scottylabs") – realm to manage clients in
  • adminClientId (nullOr str, required when url is set) – client_id of the service-account client kennel authenticates as (typically "kennel", provisioned in tofu with the realm-management/manage-clients role)
  • adminClientSecretFile (nullOr path, required when url is set) – path to a file containing the admin client secret. Typically populated by bao-agent from secret/data/infra/kennel-keycloak-admin

What the module configures

  • A systemd service for kennel with Delegate=yes for cgroup v2 access
  • A polkit rule allowing the kennel user to create transient systemd units via D-Bus
  • A kennel.slice for all managed deployment units
  • A Caddy virtualhost for the kennel domain with automatic TLS, plus the admin API for dynamic route management
  • tmpfiles rules for /var/lib/kennel subdirectories
  • Firewall rules for ports 80 and 443
  • Cachix binary cache substituter