dunkirk.sh
Kieran's opinionated NixOS infrastructure — declarative server config, self-hosted services, and automated deployments.
Layout
~/dots
├── .github/workflows # CI/CD (deploy-rs + per-service reusable workflow)
├── dots # config files symlinked by home-manager
│ └── wallpapers
├── machines
│ ├── atalanta # macOS M4 (nix-darwin)
│ ├── ember # dell r210 server (basement)
│ ├── moonlark # framework 13 (dead)
│ ├── nest # shared tilde server (home-manager only)
│ ├── prattle # oracle cloud x86_64
│ ├── tacyon # rpi 5
│ └── terebithia # oracle cloud aarch64 (main server)
├── modules
│ ├── lib
│ │ └── mkService.nix # service factory (see Deployment section)
│ ├── home # home-manager modules
│ │ ├── aesthetics # theming and wallpapers
│ │ ├── apps # app configs (ghostty, helix, git, ssh, etc.)
│ │ ├── system # shell, environment
│ │ └── wm/hyprland
│ └── nixos # nixos modules
│ ├── apps # system-level app configs
│ ├── services # self-hosted services (mkService-based + custom)
│ │ ├── restic # backup system with CLI
│ │ └── bore # tunnel proxy
│ └── system # pam, wifi
├── packages # custom nix packages
└── secrets # agenix-encrypted secrets
Machines
| Name | Platform | Role |
|---|---|---|
| terebithia | Oracle Cloud aarch64 | Main server — runs all services |
| prattle | Oracle Cloud x86_64 | Secondary server |
| atalanta | macOS M4 | Development laptop (nix-darwin) |
| ember | Dell R210 | Basement server |
| tacyon | Raspberry Pi 5 | Edge device |
| nest | Shared tilde | Home-manager only |
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.
# manual deploy
nix run 'github:serokell/deploy-rs' -- --remote-build --ssh-user kierank .
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.
mkService
modules/lib/mkService.nix standardizes service modules. A call to mkService { ... } provides:
- Systemd service with initial git clone (subsequent deploys via GitHub Actions)
- Caddy reverse proxy with TLS via Cloudflare DNS and optional rate limiting
- Data declarations (
sqlite,postgres,files) that feed into automatic backups - Dedicated system user with sudo for restart/stop/start (enables per-user Tailscale ACLs)
- Port conflict detection, security hardening, agenix secrets
Adding a new service
- Create a module in
modules/nixos/services/ - Enable it in
machines/terebithia/default.nix - Add a deploy workflow to the app repo
See modules/nixos/services/cachet.nix for a minimal example.
Machine health checks
Machines with Tailscale enabled automatically expose their hostname for reachability checks in the services manifest via atelier.machine.tailscaleHost. This defaults to networking.hostName when services.tailscale.enable is true.
Services
Services are grouped by machine in the services manifest. Machines with Tailscale enabled automatically expose their hostname for reachability checks via atelier.machine.tailscaleHost.
Machines
| Machine | Platform | Tailscale |
|---|---|---|
| terebithia | Oracle Cloud aarch64 | terebithia |
| moonlark | — | — |
| prattle | — | — |
terebithia
All services run behind Caddy with Cloudflare DNS TLS.
mkService-based
| Service | Domain | Port | Runtime | Description |
|---|---|---|---|---|
| cachet | cachet.dunkirk.sh | 3000 | bun | Slack emoji/profile cache |
| hn-alerts | hn.dunkirk.sh | 3001 | bun | Hacker News monitoring |
| indiko | indiko.dunkirk.sh | 3003 | bun | IndieAuth/OAuth2 server |
| l4 | l4.dunkirk.sh | 3004 | bun | Image CDN — Slack image optimizer |
| canvas-mcp | canvas.dunkirk.sh | 3006 | bun | Canvas MCP server |
| control | control.dunkirk.sh | 3010 | bun | Admin dashboard for Caddy toggles |
| traverse | traverse.dunkirk.sh | 4173 | bun | Code walkthrough diagram server |
| cedarlogic | cedarlogic.dunkirk.sh | 3100 | custom | Circuit simulator |
Multi-instance
| Service | Domain | Port | Description |
|---|---|---|---|
| emojibot-hackclub | hc.emojibot.dunkirk.sh | 3002 | Emojibot for Hack Club |
| emojibot-df1317 | df.emojibot.dunkirk.sh | 3005 | Emojibot for df1317 |
Custom / external
| Service | Domain | Description |
|---|---|---|
| bore (frps) | bore.dunkirk.sh | HTTP/TCP/UDP tunnel proxy |
| herald | herald.dunkirk.sh | Git SSH hosting + email |
| knot | knot.dunkirk.sh | Tangled git hosting |
| spindle | spindle.dunkirk.sh | Tangled CI |
| battleship-arena | battleship.dunkirk.sh | Battleship game server |
| n8n | n8n.dunkirk.sh | Workflow automation |
Services manifest
The manifest is now grouped by machine. Evaluate with:
nix eval --json .#services-manifest
Output shape:
{
"terebithia": {
"hostname": "terebithia",
"tailscale_host": "terebithia",
"services": [{ "name": "cachet", "health_url": "https://cachet.dunkirk.sh/health", ... }]
}
}
Architecture
Each mkService module provides:
- Systemd service — initial git clone for scaffolding, subsequent deploys via GitHub Actions
- Caddy reverse proxy — TLS via Cloudflare DNS challenge, optional rate limiting
- Data declarations —
sqlite,postgres,filesfeed into automatic backups - Dedicated user — sudo for restart/stop/start, per-user Tailscale SSH ACLs
- Port conflict detection — assertions prevent two services binding the same port
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.
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.
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 |
battleship-arena
Battleship game server with web interface and SSH-based bot submission.
Domain: battleship.dunkirk.sh · Web Port: 8081 · SSH Port: 2222
This is a custom module — it does not use mkService.
Options
| Option | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Enable battleship-arena |
domain | string | "battleship.dunkirk.sh" | Domain for Caddy reverse proxy |
sshPort | port | 2222 | SSH port for bot submissions |
webPort | port | 8081 | Web interface port |
uploadDir | string | "/var/lib/battleship-arena/submissions" | Bot upload directory |
resultsDb | string | "/var/lib/battleship-arena/results.db" | SQLite results database path |
adminPasscode | string | "battleship-admin-override" | Admin passcode |
secretsFile | path or null | null | Agenix secrets file |
package | package | — | Battleship-arena package (from flake input) |
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.
Backups
Services are automatically backed up nightly using restic to Backblaze B2. Backup targets are auto-discovered from data.sqlite/data.postgres/data.files declarations in mkService modules.
Schedule
- Time: 02:00 AM daily
- Random delay: 0–2 hours (spreads load across services)
- Retention: 3 snapshots, 7 daily, 5 weekly, 12 monthly
CLI
The atelier-backup command provides an interactive TUI:
sudo atelier-backup # Interactive menu
sudo atelier-backup status # Show backup status for all services
sudo atelier-backup list # Browse snapshots
sudo atelier-backup backup # Trigger manual backup
sudo atelier-backup restore # Interactive restore wizard
sudo atelier-backup dr # Disaster recovery mode
Service integration
Automatic (mkService)
Services using mkService with data.* declarations get automatic backup:
mkService {
name = "myapp";
extraConfig = cfg: {
atelier.services.myapp.data = {
sqlite = "${cfg.dataDir}/data/app.db"; # Auto WAL checkpoint + stop/start
files = [ "${cfg.dataDir}/uploads" ]; # Just backed up, no hooks
};
};
}
The backup system automatically checkpoints SQLite WAL, stops the service during backup, and restarts after completion.
Manual registration
For services not using mkService:
atelier.backup.services.myservice = {
paths = [ "/var/lib/myservice" ];
exclude = [ "*.log" "cache/*" ];
preBackup = "systemctl stop myservice";
postBackup = "systemctl start myservice";
};
Disaster recovery
On a fresh NixOS install:
- Rebuild from flake:
nixos-rebuild switch --flake .#hostname - Run:
sudo atelier-backup dr - All services restored from latest snapshots
Setup
- Create a B2 bucket and application key
- Create agenix secrets for
restic/password,restic/env,restic/repo - Enable:
atelier.backup.enable = true;
See modules/nixos/services/restic/README.md for full setup details.
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.
NixOS modules
| Module | Namespace | Description |
|---|---|---|
| tuigreet | atelier.apps.tuigreet | Login greeter with 30+ typed options |
| wifi | atelier.network.wifi | Declarative Wi-Fi profiles with eduroam support |
| authentication | atelier.authentication | Fingerprint + PAM stack (fprintd, polkit, gnome-keyring) |
Home-manager modules
| Module | Namespace | Description |
|---|---|---|
| shell | atelier.shell | Zsh + oh-my-posh + Tangled workflow tooling |
| ssh | atelier.ssh | SSH config with zmx persistent sessions |
| helix | atelier.apps.helix | Evil-helix with 15+ LSPs, wakatime, harper |
| bore (client) | atelier.bore | Tunnel client CLI for the bore server |
| pbnj | atelier.pbnj | Pastebin CLI with language detection |
| wut | atelier.shell.wut | Git worktree manager |
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 = [ ... ]; }; }