dunkirk.sh
Kieran’s opinionated NixOS infrastructure. Declarative server config, self-hosted services, automated deployments.
For machine inventory, apply commands, repo layout, and conventions see AGENTS.md.
- Installation — getting started on macOS, NixOS, or home-manager
- Deployment — CI/CD workflows for infrastructure and application code
- Services — architecture overview and service documentation
- Secrets — agenix workflow
- mkService — the service factory reference
- Modules — custom NixOS and home-manager modules
Live status at infra.dunkirk.sh. Machine manifest: nix eval --json .#services-manifest.
Installation
Warning: This configuration will not work without changing the secrets since they are encrypted with agenix.
macOS with nix-darwin
- Install Nix:
curl -fsSL https://install.determinate.systems/nix | sh -s -- install
- Clone and apply:
git clone git@github.com:taciturnaxolotl/dots.git
cd dots
darwin-rebuild switch --flake .#atalanta
Home Manager
Install Nix, copy SSH keys, then:
curl -fsSL https://install.determinate.systems/nix | sh -s -- install --determinate
git clone git@github.com:taciturnaxolotl/dots.git
cd dots
nix-shell -p home-manager
home-manager switch --flake .#nest
Set up atuin for shell history sync:
atuin login
atuin import
NixOS
Using nixos-anywhere (recommended for remote)
Only works with
prattleandterebithiawhich have disko configs.
nix run github:nix-community/nixos-anywhere -- \
--flake .#prattle \
--generate-hardware-config nixos-facter ./machines/prattle/facter.json \
--build-on-remote \
root@<ip-address>
Using the install script
curl -L https://raw.githubusercontent.com/taciturnaxolotl/dots/main/install.sh -o install.sh
chmod +x install.sh
./install.sh
Post-install
After first boot, log in with user kierank and the default password, then:
passwd kierank
sudo mv /etc/nixos ~/dots
sudo ln -s ~/dots /etc/nixos
sudo chown -R $(id -un):users ~/dots
atuin login && atuin sync
Deployment
Two deploy paths: infrastructure (NixOS config changes) and application code (per-service repos).
Infrastructure
Pushing to main triggers .github/workflows/deploy.yaml which runs deploy-rs over Tailscale to rebuild NixOS on the target machine.
# From the dev shell (preferred)
nix develop
deploy .#terebithia
deploy .#prattle
# Manual one-off
nix run 'github:serokell/deploy-rs' -- --remote-build --ssh-user kierank .#terebithia
Builds happen on the target machine (--remote-build), so CI only needs Nix and network access.
Application Code
Each service repo has a minimal workflow calling the reusable .github/workflows/deploy-service.yml. On push to main:
- Connects to Tailscale (
tag:deploy) - SSHes as the service user (e.g.,
cachet@terebithia) via Tailscale SSH - Snapshots the SQLite DB (if
db_pathis provided) git pull+bun install --frozen-lockfile+sudo systemctl restart- Health check (HTTP URL or systemd status fallback)
- Auto-rollback on failure (restores DB snapshot + reverts to previous commit)
Per-app workflow. Copy and change the with: values:
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
uses: taciturnaxolotl/dots/.github/workflows/deploy-service.yml@main
with:
service: cachet
health_url: https://cachet.dunkirk.sh/health
db_path: /var/lib/cachet/data/cachet.db
secrets:
TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }}
TS_OAUTH_SECRET: ${{ secrets.TS_OAUTH_SECRET }}
Omit health_url to fall back to systemctl is-active. Omit db_path for stateless services.
Adding a new service
- Create a module in
modules/nixos/services/using mkService or a custom module - Register secrets in
secrets/secrets.nixand encrypt with agenix - Enable in the target machine’s
default.nix - Add a deploy workflow to the app repo (if it has one)
See modules/nixos/services/cachet.nix for a minimal mkService example.
Services
All services run behind Caddy with Cloudflare DNS TLS. Most use the mkService factory which provides systemd units, dedicated users, reverse proxy, backup integration, and port conflict detection.
Live status
- Dashboard: infra.dunkirk.sh
- Machine manifest:
nix eval --json .#services-manifestor/services.json
Service documentation
These services have detailed option references and architecture notes:
- bore — HTTP/TCP/UDP tunnel proxy with optional OAuth
- cedarlogic — circuit simulator with WebSocket collaboration
- control — admin dashboard for Caddy feature toggles
- emojibot — multi-instance Slack emoji management
- herald — git SSH hosting with email via SMTP/DKIM
- knot-sync — mirrors Tangled knot repos to GitHub on cron
For all other services, check the manifest or the module source in modules/nixos/services/.
bore (server)
Lightweight tunneling server built on frp. Supports HTTP (wildcard subdomains), TCP, and UDP tunnels with optional OAuth authentication via Indiko.
Domain: bore.dunkirk.sh · frp port: 7000
This is a custom module — it does not use mkService.
Options
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable bore server |
domain | string | — | Base domain for wildcard subdomains |
bindAddr | string | "0.0.0.0" | frps bind address |
bindPort | port | 7000 | frps bind port |
vhostHTTPPort | port | 7080 | Virtual host HTTP port |
allowedTCPPorts | list of ports | 20000–20099 | Ports available for TCP tunnels |
allowedUDPPorts | list of ports | 20000–20099 | Ports available for UDP tunnels |
authToken | string or null | null | frp auth token (use authTokenFile instead) |
authTokenFile | path or null | null | Path to file containing frp auth token |
enableCaddy | bool | true | Auto-configure Caddy wildcard vhost |
Authentication
When enabled, all HTTP tunnels are gated behind Indiko OAuth. Users must sign in before accessing tunneled services.
| Option | Type | Default | Description |
|---|---|---|---|
auth.enable | bool | false | Enable bore-auth OAuth middleware |
auth.indikoURL | string | "https://indiko.dunkirk.sh" | Indiko server URL |
auth.clientID | string | — | OAuth client ID from Indiko |
auth.clientSecretFile | path | — | Path to OAuth client secret |
auth.cookieHashKeyFile | path | — | 32-byte cookie signing key |
auth.cookieBlockKeyFile | path | — | 32-byte cookie encryption key |
After authentication, these headers are passed to tunneled services:
X-Auth-User— user’s profile URLX-Auth-Name— display nameX-Auth-Email— email address
See bore (client) for the home-manager client module.
cedarlogic
Browser-based circuit simulator with real-time collaboration via WebSockets.
Domain: cedarlogic.dunkirk.sh · Port: 3100 · Runtime: custom
Extra options
| Option | Type | Default | Description |
|---|---|---|---|
wsPort | port | 3101 | Hocuspocus WebSocket server for document collaboration |
cursorPort | port | 3102 | Cursor relay WebSocket server for live cursors |
branch | string | "web" | Git branch to clone (uses web branch, not main) |
Caddy routing
Cedarlogic disables the default mkService Caddy config and uses path-based routing to three backends:
| Path | Backend |
|---|---|
/ws | wsPort (Hocuspocus) |
/cursor-ws | cursorPort (cursor relay) |
/api/*, /auth/* | main port |
| Everything else | Static files from dist/ |
Build step
On initial scaffold, cedarlogic installs deps and builds:
bun install → parse-gates → bun run build (Vite)
Subsequent deploys handle their own build via the deploy workflow. The build has a 120s timeout to accommodate Vite compilation.
control
Admin dashboard for Caddy feature toggles. Provides a web UI to enable/disable paths on other services (e.g. blocking player tracking on the map).
Domain: control.dunkirk.sh · Port: 3010 · Runtime: bun
Extra options
flags
Defines per-domain feature flags that control blocks paths and redacts JSON fields.
atelier.services.control.flags."map.dunkirk.sh" = {
name = "Map";
flags = {
"block-tracking" = {
name = "Block Player Tracking";
description = "Disable real-time player location updates";
paths = [
"/sse"
"/sse/*"
"/tiles/*/markers/pl3xmap_players.json"
];
redact."/tiles/settings.json" = [ "players" ];
};
};
};
| Option | Type | Default | Description |
|---|---|---|---|
flags | attrsOf submodule | {} | Services and their feature flags, keyed by domain |
flags.<domain>.name | string | — | Display name for the service |
flags.<domain>.flags.<id>.name | string | — | Display name for the flag |
flags.<domain>.flags.<id>.description | string | — | What the flag does |
flags.<domain>.flags.<id>.paths | list of strings | [] | URL paths to block when flag is active |
flags.<domain>.flags.<id>.redact | attrsOf (list of strings) | {} | JSON fields to redact from responses, keyed by path |
The flags config is serialized to flags.json and passed to control via the FLAGS_CONFIG environment variable.
emojibot
Slack emoji management service. Supports multiple instances for different workspaces.
Runtime: bun · Stateless (no database)
This is a custom module — it does not use mkService. Each instance gets its own systemd service, user, and Caddy virtual host.
Instance options
Instances are defined under atelier.services.emojibot.instances.<name>:
atelier.services.emojibot.instances = {
hackclub = {
enable = true;
domain = "hc.emojibot.dunkirk.sh";
port = 3002;
workspace = "hackclub";
channel = "C02T3CU03T3";
repository = "https://github.com/taciturnaxolotl/emojibot";
secretsFile = config.age.secrets."emojibot/hackclub".path;
healthUrl = "https://hc.emojibot.dunkirk.sh/health";
};
};
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable this instance |
domain | string | — | Domain for Caddy reverse proxy |
port | port | — | Port to run on |
secretsFile | path | — | Agenix secrets file with Slack credentials |
repository | string | "https://github.com/taciturnaxolotl/emojibot" | Git repo URL |
workspace | string or null | null | Slack workspace name (for identification) |
channel | string or null | null | Slack channel ID |
healthUrl | string or null | null | Health check URL for monitoring |
Current instances
| Instance | Domain | Port | Workspace |
|---|---|---|---|
| hackclub | hc.emojibot.dunkirk.sh | 3002 | Hack Club |
| df1317 | df.emojibot.dunkirk.sh | 3005 | df1317 |
herald
Git SSH hosting with email notifications. Provides a git push interface over SSH and sends email via SMTP/DKIM.
Domain: herald.dunkirk.sh · SSH Port: 2223 · HTTP Port: 8085
This is a custom module — it does not use mkService.
Options
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable herald |
domain | string | — | Domain for Caddy reverse proxy |
host | string | "0.0.0.0" | Listen address |
sshPort | port | 2223 | SSH listen port |
externalSshPort | port | 2223 | External SSH port (if behind NAT) |
httpPort | port | 8085 | HTTP API port |
dataDir | path | "/var/lib/herald" | Data directory |
allowAllKeys | bool | true | Allow all SSH keys |
secretsFile | path | — | Agenix secrets (must contain SMTP_PASS) |
package | package | pkgs.herald | Herald package |
SMTP
| Option | Type | Default | Description |
|---|---|---|---|
smtp.host | string | — | SMTP server hostname |
smtp.port | port | 587 | SMTP server port |
smtp.user | string | — | SMTP username |
smtp.from | string | — | Sender address |
DKIM
| Option | Type | Default | Description |
|---|---|---|---|
smtp.dkim.selector | string or null | null | DKIM selector |
smtp.dkim.domain | string or null | null | DKIM signing domain |
smtp.dkim.privateKeyFile | path or null | null | Path to DKIM private key |
knot-sync
Mirrors Tangled knot repositories to GitHub on a cron schedule.
This is a custom module — it does not use mkService. Runs as a systemd timer, not a long-running service.
Options
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable knot-sync |
repoDir | string | "/home/git/did:plc:..." | Directory containing knot git repos |
githubUsername | string | "taciturnaxolotl" | GitHub username to mirror to |
secretsFile | path | — | Agenix secrets (must contain GITHUB_TOKEN) |
logFile | string | "/home/git/knot-sync.log" | Log file path |
interval | string | "*/5 * * * *" | Cron schedule for sync |
Secrets
Secrets are managed using agenix — encrypted at rest in the repo and decrypted at activation time to /run/agenix/.
Usage
Create or edit a secret:
cd secrets && agenix -e myapp.age
The secret file contains environment variables, one per line:
DATABASE_URL=postgres://...
API_KEY=xxxxx
SECRET_TOKEN=yyyyy
Adding a new secret
- Add the public key entry to
secrets/secrets.nix:
"service-name.age".publicKeys = [ kierank ];
- Create and encrypt the secret:
agenix -e secrets/service-name.age
- Declare in machine config:
age.secrets.service-name = {
file = ../../secrets/service-name.age;
owner = "service-name";
};
- Reference as
config.age.secrets.service-name.pathin the service module.
Identity paths
The decryption keys are SSH keys configured per machine:
age.identityPaths = [
"/home/kierank/.ssh/id_rsa"
"/etc/ssh/id_rsa"
];
Modules
Custom NixOS and home-manager modules under the atelier.* namespace. These wrap and extend upstream packages with opinionated defaults and structured configuration.
All modules live under modules/nixos/ and modules/home/. Machines using import-tree automatically discover every .nix file in those trees. Modules only activate when their enable option is set to true.
Documented modules
These have detailed option references and examples:
NixOS
- tuigreet — login greeter (
atelier.apps.tuigreet) - wifi — declarative Wi-Fi profiles with eduroam (
atelier.network.wifi)
Home-manager
- shell — zsh + oh-my-posh + Tangled tooling (
atelier.shell) - ssh — SSH config with zmx persistent sessions (
atelier.ssh) - helix — evil-helix with LSPs, wakatime, harper (
atelier.apps.helix) - bore (client) — tunnel client CLI (
atelier.bore) - pbnj — pastebin CLI (
atelier.pbnj) - wut — git worktree manager (
atelier.shell.wut)
Other modules
Many more modules exist without dedicated doc pages. Browse the source:
modules/home/apps/— ghostty, alacritty, git, jj, qutebrowser, spotify, halloy, irssi, tofimodules/home/aesthetics/— theming (Catppuccin), wallpapersmodules/home/wm/— hyprland, yabai/skhdmodules/nixos/system/— authentication, machine metadatamodules/nixos/services/— 20+ service modules (see Services)
tuigreet
Configures greetd with tuigreet as the login greeter. Exposes nearly every tuigreet CLI flag as a typed Nix option.
Options
All options under atelier.apps.tuigreet:
Core
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable tuigreet |
command | string | "Hyprland" | Session command to run after login |
greeting | string | (unauthorized access warning) | Greeting message |
Display
| Option | Type | Default | Description |
|---|---|---|---|
time | bool | false | Show clock |
timeFormat | string | "%H:%M" | Clock format |
issue | bool | false | Show /etc/issue |
width | int | 80 | UI width |
theme | string | "" | Theme string |
asterisks | bool | false | Show asterisks for password |
asterisksChar | string | "*" | Character for password masking |
Layout
| Option | Type | Default | Description |
|---|---|---|---|
windowPadding | int | 0 | Window padding |
containerPadding | int | 1 | Container padding |
promptPadding | int | 1 | Prompt padding |
greetAlign | enum | "center" | Greeting alignment: left, center, right |
Session management
| Option | Type | Default | Description |
|---|---|---|---|
remember | bool | false | Remember last username |
rememberSession | bool | false | Remember last session |
rememberUserSession | bool | false | Per-user session memory |
sessions | string | "" | Wayland session search path |
xsessions | string | "" | X11 session search path |
sessionWrapper | string | "" | Session wrapper command |
User menu
| Option | Type | Default | Description |
|---|---|---|---|
userMenu | bool | false | Show user selection menu |
userMenuMinUid | int | 1000 | Minimum UID in user menu |
userMenuMaxUid | int | 65534 | Maximum UID in user menu |
Power commands
| Option | Type | Default | Description |
|---|---|---|---|
powerShutdown | string | "" | Shutdown command |
powerReboot | string | "" | Reboot command |
Keybindings
| Option | Type | Default | Description |
|---|---|---|---|
kbCommand | enum | "F2" | Key to switch command |
kbSessions | enum | "F3" | Key to switch session |
kbPower | enum | "F12" | Key for power menu |
wifi
Declarative Wi-Fi profile manager using NetworkManager. Supports three ways to supply passwords and has built-in eduroam (WPA-EAP) support.
Options
All options under atelier.network.wifi:
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable Wi-Fi management |
hostName | string | — | Sets networking.hostName |
nameservers | list of strings | [] | Custom DNS servers |
envFile | path | — | Environment file providing PSK variables for all profiles |
Profiles
Defined under atelier.network.wifi.profiles.<ssid>:
| Option | Type | Default | Description |
|---|---|---|---|
psk | string or null | null | Literal WPA-PSK passphrase |
pskVar | string or null | null | Environment variable name containing the PSK (from envFile) |
pskFile | path or null | null | Path to file containing the PSK |
eduroam | bool | false | Use WPA-EAP with MSCHAPV2 (for eduroam networks) |
identity | string or null | null | EAP identity (required when eduroam = true) |
Only one of psk, pskVar, or pskFile should be set per profile.
Example
atelier.network.wifi = {
enable = true;
hostName = "moonlark";
nameservers = [ "1.1.1.1" "8.8.8.8" ];
envFile = config.age.secrets.wifi.path;
profiles = {
"Home Network" = {
pskVar = "HOME_PSK"; # read from envFile
};
"eduroam" = {
eduroam = true;
identity = "user@university.edu";
pskVar = "EDUROAM_PSK";
};
"Phone Hotspot" = {
pskFile = config.age.secrets.hotspot.path;
};
};
};
shell
Zsh configuration with oh-my-posh prompt, syntax highlighting, fzf-tab, zoxide, and Tangled git workflow tooling.
Options
All options under atelier.shell:
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable shell configuration |
Tangled
Options for the tangled-setup and mkdev scripts that manage dual-remote git workflows (Tangled knot + GitHub).
| Option | Type | Default | Description |
|---|---|---|---|
tangled.plcId | string | — | ATProto DID for Tangled identity |
tangled.githubUser | string | — | GitHub username |
tangled.knotHost | string | — | Knot git host (e.g. knot.dunkirk.sh) |
tangled.domain | string | — | Tangled domain for repo URLs |
tangled.defaultBranch | string | "main" | Default branch name |
Included tools
tangled-setup— configures a repo withoriginpointing to knot andgithubpointing to GitHubmkdev— creates a new repo on both Tangled and GitHub simultaneously- oh-my-posh — custom prompt showing path, git status (ahead/behind), exec time, nix-shell indicator, ZMX session, SSH hostname
- Aliases —
cat=bat,ls=eza,cd=z(zoxide), and more
ssh
Declarative SSH config with per-host options and zmx (persistent tmux-like sessions over SSH) integration.
Options
All options under atelier.ssh:
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable SSH config management |
extraConfig | string | "" | Raw SSH config appended to the end |
zmx
| Option | Type | Default | Description |
|---|---|---|---|
zmx.enable | bool | false | Install zmx and autossh |
zmx.hosts | list of strings | [] | Host patterns to auto-attach via zmx |
When zmx is enabled for a host, the SSH config injects RemoteCommand, RequestTTY force, and ControlMaster/ControlPersist settings. Shell aliases are also added: zmls, zmk, zma, ash.
Hosts
Per-host config under atelier.ssh.hosts.<name>:
| Option | Type | Default | Description |
|---|---|---|---|
hostname | string | — | SSH hostname or IP |
port | int or null | null | SSH port |
user | string or null | null | SSH user |
identityFile | string or null | null | Path to SSH key |
forwardAgent | bool | false | Forward SSH agent |
zmx | bool | false | Enable zmx for this host |
extraOptions | attrsOf string | {} | Arbitrary SSH options |
Example
atelier.ssh = {
enable = true;
zmx.enable = true;
zmx.hosts = [ "terebithia" "ember" ];
hosts = {
terebithia = {
hostname = "terebithia";
user = "kierank";
forwardAgent = true;
zmx = true;
};
"github.com" = {
identityFile = "~/.ssh/id_rsa";
};
};
};
helix
Evil-helix (vim-mode fork) with comprehensive LSP setup, wakatime tracking on every language, and harper grammar checking.
Options
All options under atelier.apps.helix:
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable helix configuration |
swift | bool | false | Add sourcekit-lsp for Swift (platform-conditional) |
Language servers
The module configures 15+ language servers out of the box:
| Language | Server |
|---|---|
| Nix | nixd + nil |
| TypeScript/JavaScript | typescript-language-server + biome |
| Go | gopls |
| Python | pylsp |
| Rust | rust-analyzer |
| HTML/CSS | vscode-html-language-server, vscode-css-language-server |
| JSON | vscode-json-language-server + biome |
| TOML | taplo |
| Markdown | marksman |
| YAML | yaml-language-server |
| Swift | sourcekit-lsp (when swift = true) |
All languages also get:
- wakatime-ls — coding time tracking
- harper-ls — grammar and spell checking
Note: After install, run
hx -g fetch && hx -g buildto compile tree-sitter grammars.
bore (client)
Interactive CLI for creating tunnels to the bore server. Built with gum, supports HTTP, TCP, and UDP tunnels.
Options
All options under atelier.bore:
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Install the bore CLI |
serverAddr | string | "bore.dunkirk.sh" | frps server address |
serverPort | port | 7000 | frps server port |
domain | string | "bore.dunkirk.sh" | Base domain for constructing public URLs |
authTokenFile | path | — | Path to frp auth token file |
Usage
bore # Interactive menu
bore myapp 3000 # Quick HTTP tunnel: myapp.bore.dunkirk.sh → localhost:3000
bore myapp 3000 --auth # With OAuth authentication
bore myapp 3000 --save # Save to bore.toml for reuse
Tunnels can also be defined in a bore.toml:
[myapp]
port = 3000
auth = true
labels = ["dev"]
pbnj
Pastebin CLI with automatic language detection, clipboard integration, and agenix auth.
Options
All options under atelier.pbnj:
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Install the pbnj CLI |
host | string | — | Pastebin instance URL |
authKeyFile | path | — | Path to auth key file (e.g. agenix secret) |
Usage
pbnj # Interactive menu
pbnj upload myfile.py # Upload file (auto-detects Python)
cat output.log | pbnj upload # Upload from stdin
pbnj list # List pastes
pbnj delete <id> # Delete a paste
Supports 25+ languages via file extension detection. Automatically copies the URL to clipboard (wl-copy/xclip/pbcopy depending on platform).
wut
Worktrees Unexpectedly Tolerable — a git worktree manager that keeps worktrees organized under .worktrees/.
Options
| Option | Type | Default | Description |
|---|---|---|---|
atelier.shell.wut.enable | bool | false | Install wut and the zsh shell wrapper |
Usage
wut new feat/my-feature # Create worktree + branch under .worktrees/
wut list # Show all worktrees
wut go feat/my-feature # cd into worktree (via shell wrapper)
wut go # Interactive picker
wut path feat/my-feature # Print worktree path
wut rm feat/my-feature # Remove worktree + delete branch
Shell integration
Wut needs to cd the calling shell, which a subprocess can’t do directly. It works by printing a __WUT_CD__=/path marker that a zsh wrapper function intercepts:
wut() {
output=$(/path/to/wut "$@")
if [[ "$output" == *"__WUT_CD__="* ]]; then
cd "${output##*__WUT_CD__=}"
else
echo "$output"
fi
}
This wrapper is automatically injected into initContent when the module is enabled.
Safety
wut rmrefuses to delete worktrees with uncommitted changes (use--forceto override)wut rmwarns before deleting unmerged branches- The main/master branch worktree cannot be removed
mkService
modules/lib/mkService.nix is the service factory used by most atelier services. It takes a set of parameters and returns a NixOS module with standardized options, systemd service, Caddy reverse proxy, and backup integration.
Factory parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
name | string | required | Service identity — used for user, group, systemd unit, and option namespace |
description | string | "<name> service" | Human-readable description |
defaultPort | int | 3000 | Default port if not overridden in config |
runtime | string | "bun" | "bun", "node", or "custom" |
entryPoint | string | "src/index.ts" | Script to run (ignored if startCommand is set) |
startCommand | string | null | Override the full start command |
extraOptions | attrset | {} | Additional NixOS options for this service |
extraConfig | function | cfg: {} | Additional NixOS config when enabled (receives the service config) |
Options
Every mkService module creates options under atelier.services.<name>:
Core
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable the service |
domain | string | required | Domain for Caddy reverse proxy |
port | port | defaultPort | Port the service listens on |
dataDir | path | "/var/lib/<name>" | Data storage directory |
secretsFile | path or null | null | Agenix secrets environment file |
repository | string or null | null | Git repo URL — cloned once on first start |
healthUrl | string or null | null | Health check URL for monitoring |
environment | attrset | {} | Additional environment variables |
Data declarations
Used by the backup system to automatically discover what to back up.
| Option | Type | Default | Description |
|---|---|---|---|
data.sqlite | string or null | null | SQLite database path (WAL checkpoint + stop/start during backup) |
data.postgres | string or null | null | PostgreSQL database name (pg_dump during backup) |
data.files | list of strings | [] | Additional file paths to back up |
data.exclude | list of strings | ["*.log", "node_modules", ...] | Glob patterns to exclude |
Caddy
| Option | Type | Default | Description |
|---|---|---|---|
caddy.enable | bool | true | Enable Caddy reverse proxy |
caddy.extraConfig | string | "" | Additional Caddy directives |
caddy.rateLimit.enable | bool | false | Enable rate limiting |
caddy.rateLimit.events | int | 60 | Requests per window |
caddy.rateLimit.window | string | "1m" | Rate limit time window |
What it sets up
- System user and group — dedicated user in the
servicesgroup with sudo forsystemctl restart/stop/start/status - Systemd service —
ExecStartPrecreates dirs as root,preStartclones repo and installs deps,ExecStartruns the application - Caddy virtual host — TLS via Cloudflare DNS challenge, reverse proxy to localhost port
- Port conflict detection — assertions prevent two services from binding the same port
- Security hardening —
NoNewPrivileges,ProtectSystem=strict,ProtectHome,PrivateTmp
Example
Minimal service module:
let
mkService = import ../../lib/mkService.nix;
in
mkService {
name = "myapp";
description = "My application";
defaultPort = 3000;
runtime = "bun";
entryPoint = "src/index.ts";
extraConfig = cfg: {
systemd.services.myapp.serviceConfig.Environment = [
"DATABASE_PATH=${cfg.dataDir}/data/app.db"
];
atelier.services.myapp.data = {
sqlite = "${cfg.dataDir}/data/app.db";
};
};
}
Then enable in the machine config:
atelier.services.myapp = {
enable = true;
domain = "myapp.dunkirk.sh";
repository = "https://github.com/taciturnaxolotl/myapp";
secretsFile = config.age.secrets.myapp.path;
healthUrl = "https://myapp.dunkirk.sh/health";
};
Service utility functions
Service utility functions for the atelier infrastructure.
These functions operate on NixOS configurations to extract service metadata for dashboards, monitoring, and documentation.
services.isMkService
Check whether an atelier service config value has the standard
mkService shape (has enable, domain, port, _description).
Arguments
cfg— an attribute set fromconfig.atelier.services.<name>
Type
AttrSet -> Bool
Example
isMkService config.atelier.services.cachet
=> true
services.mkServiceEntry
Convert a single mkService config into a manifest entry.
Arguments
name— the service name (attribute key)cfg— the service config attrset
Type
String -> AttrSet -> AttrSet
Example
mkServiceEntry "cachet" config.atelier.services.cachet
=> { name = "cachet"; domain = "cachet.dunkirk.sh"; ... }
services.mkManifest
Build a services manifest from an evaluated NixOS config.
Discovers all enabled mkService-based services plus emojibot instances. Returns a sorted list of service entries suitable for JSON serialisation.
Arguments
config— the fully evaluated NixOS configuration
Type
AttrSet -> [ AttrSet ]
Example
mkManifest config
=> [ { name = "cachet"; domain = "cachet.dunkirk.sh"; ... } ... ]
services.mkMachinesManifest
Build a manifest of all machines and their services.
Takes one or more attrsets of system configurations (NixOS, Darwin,
or home-manager) and returns an attrset keyed by machine name.
Only machines with atelier.machine.enable = true are included.
Arguments
configSets— list of attrsets of system configurations
Type
[ AttrSet ] -> AttrSet
Example
mkMachinesManifest [ self.nixosConfigurations self.darwinConfigurations ]
=> { terebithia = { hostname = "terebithia"; services = [ ... ]; }; }