tangled
alpha
login
or
join now
dunkirk.sh
/
dots
3
fork
atom
Kieran's opinionated (and probably slightly dumb) nix config
3
fork
atom
overview
issues
pulls
pipelines
feat: refactor mkservice
dunkirk.sh
2 weeks ago
86560cce
a8bbcefd
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+133
-250
3 changed files
expand all
collapse all
unified
split
machines
terebithia
default.nix
modules
lib
mkService.nix
nixos
services
cedarlogic.nix
+8
-9
machines/terebithia/default.nix
···
382
382
atelier.services.cachet = {
383
383
enable = true;
384
384
domain = "cachet.dunkirk.sh";
385
385
-
deploy.repository = "https://github.com/taciturnaxolotl/cachet";
385
385
+
repository = "https://github.com/taciturnaxolotl/cachet";
386
386
secretsFile = config.age.secrets.cachet.path;
387
387
};
388
388
389
389
atelier.services.hn-alerts = {
390
390
enable = true;
391
391
domain = "hn.dunkirk.sh";
392
392
-
deploy.repository = "https://github.com/taciturnaxolotl/hn-alerts";
392
392
+
repository = "https://github.com/taciturnaxolotl/hn-alerts";
393
393
secretsFile = config.age.secrets.hn-alerts.path;
394
394
};
395
395
···
485
485
atelier.services.indiko = {
486
486
enable = true;
487
487
domain = "indiko.dunkirk.sh";
488
488
-
deploy.repository = "https://github.com/taciturnaxolotl/indiko";
488
488
+
repository = "https://github.com/taciturnaxolotl/indiko";
489
489
};
490
490
491
491
atelier.services.l4 = {
492
492
enable = true;
493
493
domain = "l4.dunkirk.sh";
494
494
port = 3004;
495
495
-
deploy.repository = "https://github.com/taciturnaxolotl/l4";
496
496
-
deploy.autoUpdate = false;
495
495
+
repository = "https://github.com/taciturnaxolotl/l4";
497
496
secretsFile = config.age.secrets.l4.path;
498
497
};
499
498
500
499
atelier.services.control = {
501
500
enable = true;
502
501
domain = "control.dunkirk.sh";
503
503
-
deploy.repository = "https://github.com/taciturnaxolotl/control";
504
504
-
deploy.autoUpdate = false;
502
502
+
repository = "https://github.com/taciturnaxolotl/control";
505
503
secretsFile = config.age.secrets.control.path;
506
504
507
505
flags."map.dunkirk.sh" = {
···
524
522
atelier.services.traverse = {
525
523
enable = true;
526
524
domain = "traverse.dunkirk.sh";
527
527
-
deploy.repository = "https://github.com/taciturnaxolotl/traverse";
525
525
+
repository = "https://github.com/taciturnaxolotl/traverse";
528
526
};
529
527
530
528
atelier.services.herald = {
···
550
548
atelier.services.canvas-mcp = {
551
549
enable = true;
552
550
domain = "canvas.dunkirk.sh";
553
553
-
deploy.repository = "https://github.com/taciturnaxolotl/canvas-mcp";
551
551
+
repository = "https://github.com/taciturnaxolotl/canvas-mcp";
554
552
secretsFile = config.age.secrets.canvas-mcp.path;
555
553
environment = {
556
554
DKIM_PRIVATE_KEY_FILE = "${config.age.secrets.canvas-mcp-dkim.path}";
···
560
558
atelier.services.cedarlogic = {
561
559
enable = true;
562
560
domain = "cedarlogic.dunkirk.sh";
561
561
+
repository = "https://github.com/taciturnaxolotl/CedarLogic";
563
562
secretsFile = config.age.secrets.cedarlogic.path;
564
563
};
565
564
+38
-64
modules/lib/mkService.nix
···
2
2
#
3
3
# Creates a standardized NixOS service module with:
4
4
# - Common options (domain, port, dataDir, secrets, etc.)
5
5
-
# - Systemd service with git-based deployment
5
5
+
# - Systemd service with initial git clone for scaffolding
6
6
# - Caddy reverse proxy configuration
7
7
# - Automatic backup integration via data declarations
8
8
+
#
9
9
+
# Subsequent deployments are handled by per-repo GitHub Actions
10
10
+
# workflows that SSH in as the service user, git pull, and restart.
8
11
#
9
12
# Usage in a service module:
10
13
# let
···
23
26
name,
24
27
description ? "${name} service",
25
28
defaultPort ? 3000,
26
26
-
29
29
+
27
30
# Runtime configuration
28
31
runtime ? "bun", # "bun" | "node" | "custom"
29
32
entryPoint ? "src/index.ts",
30
33
startCommand ? null, # Override the start command entirely
31
31
-
34
34
+
32
35
# Additional options specific to this service
33
36
extraOptions ? {},
34
34
-
37
37
+
35
38
# Additional config when service is enabled
36
39
# Receives cfg (the service config) as argument
37
40
extraConfig ? cfg: {},
···
78
81
description = "Path to agenix secrets file";
79
82
};
80
83
81
81
-
# Git-based deployment
82
82
-
deploy = {
83
83
-
enable = lib.mkEnableOption "Git-based deployment" // { default = true; };
84
84
-
85
85
-
repository = lib.mkOption {
86
86
-
type = lib.types.nullOr lib.types.str;
87
87
-
default = null;
88
88
-
description = "Git repository URL for auto-deployment";
89
89
-
};
90
90
-
91
91
-
autoUpdate = lib.mkEnableOption "Automatically git pull on service restart";
92
92
-
93
93
-
branch = lib.mkOption {
94
94
-
type = lib.types.str;
95
95
-
default = "main";
96
96
-
description = "Git branch to deploy";
97
97
-
};
84
84
+
# Git repository for initial scaffolding (clone on first start)
85
85
+
# Subsequent deploys are handled by GitHub Actions workflows
86
86
+
repository = lib.mkOption {
87
87
+
type = lib.types.nullOr lib.types.str;
88
88
+
default = null;
89
89
+
description = "Git repository URL — cloned once on first start for scaffolding";
98
90
};
99
91
100
92
# Data declarations for automatic backup
···
225
217
after = [ "network.target" ];
226
218
path = [ pkgs.git pkgs.openssh ];
227
219
228
228
-
preStart = lib.optionalString (cfg.deploy.enable && cfg.deploy.repository != null) ''
220
220
+
preStart = lib.optionalString (cfg.repository != null) ''
229
221
set -e
230
230
-
# Clone repository if not present
222
222
+
# Clone repository on first start (scaffolding only)
231
223
if [ ! -d ${cfg.dataDir}/app/.git ]; then
232
232
-
${pkgs.git}/bin/git clone -b ${cfg.deploy.branch} ${cfg.deploy.repository} ${cfg.dataDir}/app
224
224
+
${pkgs.git}/bin/git clone ${cfg.repository} ${cfg.dataDir}/app
233
225
fi
234
234
-
235
235
-
cd ${cfg.dataDir}/app
236
236
-
'' + lib.optionalString (cfg.deploy.enable && cfg.deploy.autoUpdate) ''
237
237
-
${pkgs.git}/bin/git fetch origin || true
238
238
-
${pkgs.git}/bin/git reset --hard origin/${cfg.deploy.branch} || true
239
226
'' + lib.optionalString (runtime == "bun") ''
240
240
-
241
241
-
if [ -f package.json ]; then
242
242
-
echo "Installing dependencies..."
243
243
-
${pkgs.unstable.bun}/bin/bun install || {
244
244
-
echo "Failed to install dependencies, trying again..."
245
245
-
${pkgs.unstable.bun}/bin/bun install
246
246
-
}
227
227
+
228
228
+
# Install deps only on first clone (no node_modules yet)
229
229
+
if [ -f ${cfg.dataDir}/app/package.json ] && [ ! -d ${cfg.dataDir}/app/node_modules ]; then
230
230
+
cd ${cfg.dataDir}/app
231
231
+
echo "First start: installing dependencies..."
232
232
+
${pkgs.unstable.bun}/bin/bun install
247
233
fi
248
234
'' + lib.optionalString (runtime == "node") ''
249
249
-
250
250
-
if [ -f package.json ]; then
251
251
-
echo "Installing dependencies..."
252
252
-
${pkgs.nodejs_20}/bin/npm ci --production || {
253
253
-
echo "Failed to install dependencies, trying again..."
254
254
-
${pkgs.nodejs_20}/bin/npm ci --production
255
255
-
}
235
235
+
236
236
+
# Install deps only on first clone (no node_modules yet)
237
237
+
if [ -f ${cfg.dataDir}/app/package.json ] && [ ! -d ${cfg.dataDir}/app/node_modules ]; then
238
238
+
cd ${cfg.dataDir}/app
239
239
+
echo "First start: installing dependencies..."
240
240
+
${pkgs.nodejs_20}/bin/npm ci --production
256
241
fi
257
242
'';
258
243
···
271
256
RestartSec = "10s";
272
257
TimeoutStartSec = "60s";
273
258
274
274
-
# Automatic state directory management
275
275
-
# Creates /var/lib/${name} with proper ownership before namespace setup
276
276
-
StateDirectory = name;
277
277
-
StateDirectoryMode = "0755";
278
278
-
279
259
# Security hardening
280
260
NoNewPrivileges = true;
281
261
ProtectSystem = "strict";
282
262
ProtectHome = true;
283
263
PrivateTmp = true;
264
264
+
265
265
+
# ExecStartPre with ! runs as root before namespace setup,
266
266
+
# guaranteeing dirs exist before WorkingDirectory is checked
267
267
+
ExecStartPre = [
268
268
+
"!${pkgs.writeShellScript "${name}-setup" ''
269
269
+
mkdir -p ${cfg.dataDir}/app ${cfg.dataDir}/data
270
270
+
chown -R ${name}:services ${cfg.dataDir}
271
271
+
chmod -R g+rwX ${cfg.dataDir}
272
272
+
''}"
273
273
+
];
284
274
};
285
285
-
286
286
-
serviceConfig.ExecStartPre = [
287
287
-
# Run before preStart, creates directories so WorkingDirectory exists
288
288
-
"!${pkgs.writeShellScript "${name}-setup" ''
289
289
-
mkdir -p ${cfg.dataDir}/app/data
290
290
-
mkdir -p ${cfg.dataDir}/data
291
291
-
chown -R ${name}:services ${cfg.dataDir}
292
292
-
chmod -R g+rwX ${cfg.dataDir}
293
293
-
''}"
294
294
-
];
295
275
};
296
296
-
297
297
-
# StateDirectory handles base dir, tmpfiles creates subdirectories
298
298
-
systemd.tmpfiles.rules = [
299
299
-
"d ${cfg.dataDir}/app 0755 ${name} services -"
300
300
-
"d ${cfg.dataDir}/data 0755 ${name} services -"
301
301
-
];
302
276
303
277
# Caddy reverse proxy
304
278
services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable {
+87
-177
modules/nixos/services/cedarlogic.nix
···
1
1
# CedarLogic - Web-based circuit simulator
2
2
#
3
3
-
# Custom module (not mkService) because:
4
4
-
# - App lives in web/ subdirectory of the repo
5
5
-
# - Needs a Vite build step before serving
6
6
-
# - Multi-port: API (3000), Hocuspocus WS (3001), Cursor WS (3002)
7
7
-
# - Caddy needs path-based routing to different backends
3
3
+
# Multi-port service: API, Hocuspocus WS, Cursor WS
4
4
+
# App lives in web/ subdirectory, needs Vite build step
8
5
9
6
{ config, lib, pkgs, ... }:
10
7
11
8
let
9
9
+
mkService = import ../../lib/mkService.nix;
12
10
cfg = config.atelier.services.cedarlogic;
13
13
-
appDir = "${cfg.dataDir}/app";
14
14
-
webDir = "${appDir}/web";
15
15
-
in
16
16
-
{
17
17
-
options.atelier.services.cedarlogic = {
18
18
-
enable = lib.mkEnableOption "CedarLogic circuit simulator";
19
19
-
20
20
-
domain = lib.mkOption {
21
21
-
type = lib.types.str;
22
22
-
description = "Domain to serve CedarLogic on";
23
23
-
};
24
24
-
25
25
-
dataDir = lib.mkOption {
26
26
-
type = lib.types.path;
27
27
-
default = "/var/lib/cedarlogic";
28
28
-
description = "Directory to store CedarLogic data";
29
29
-
};
30
30
-
31
31
-
port = lib.mkOption {
32
32
-
type = lib.types.port;
33
33
-
default = 3100;
34
34
-
description = "Port for the HTTP API server";
35
35
-
};
36
36
-
37
37
-
wsPort = lib.mkOption {
38
38
-
type = lib.types.port;
39
39
-
default = 3101;
40
40
-
description = "Port for the Hocuspocus WebSocket server";
41
41
-
};
11
11
+
webDir = "${cfg.dataDir}/app/web";
42
12
43
43
-
cursorPort = lib.mkOption {
44
44
-
type = lib.types.port;
45
45
-
default = 3102;
46
46
-
description = "Port for the cursor relay WebSocket server";
47
47
-
};
13
13
+
baseModule = mkService {
14
14
+
name = "cedarlogic";
15
15
+
description = "CedarLogic circuit simulator";
16
16
+
defaultPort = 3100;
17
17
+
runtime = "custom";
18
18
+
startCommand = "cd ${webDir} && exec ${pkgs.unstable.bun}/bin/bun run src/server/index.ts";
48
19
49
49
-
secretsFile = lib.mkOption {
50
50
-
type = lib.types.nullOr lib.types.path;
51
51
-
default = null;
52
52
-
description = "Path to secrets file (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, JWT_SECRET)";
53
53
-
};
20
20
+
extraOptions = {
21
21
+
wsPort = lib.mkOption {
22
22
+
type = lib.types.port;
23
23
+
default = 3101;
24
24
+
description = "Port for the Hocuspocus WebSocket server";
25
25
+
};
54
26
55
55
-
deploy = {
56
56
-
repository = lib.mkOption {
57
57
-
type = lib.types.str;
58
58
-
default = "https://github.com/taciturnaxolotl/CedarLogic";
59
59
-
description = "Git repository URL";
27
27
+
cursorPort = lib.mkOption {
28
28
+
type = lib.types.port;
29
29
+
default = 3102;
30
30
+
description = "Port for the cursor relay WebSocket server";
60
31
};
61
32
62
33
branch = lib.mkOption {
63
34
type = lib.types.str;
64
35
default = "web";
65
65
-
description = "Git branch to deploy";
36
36
+
description = "Git branch to clone";
66
37
};
67
38
};
68
68
-
};
69
39
70
70
-
config = lib.mkIf cfg.enable {
71
71
-
# User and group
72
72
-
users.groups.services = {};
40
40
+
extraConfig = cfg: {
41
41
+
atelier.services.cedarlogic.environment = {
42
42
+
WS_PORT = toString cfg.wsPort;
43
43
+
CURSOR_PORT = toString cfg.cursorPort;
44
44
+
DATABASE_PATH = "${cfg.dataDir}/data/cedarlogic.db";
45
45
+
GOOGLE_REDIRECT_URI = "https://${cfg.domain}/auth/google/callback";
46
46
+
};
73
47
74
74
-
users.users.cedarlogic = {
75
75
-
isSystemUser = true;
76
76
-
group = "cedarlogic";
77
77
-
extraGroups = [ "services" ];
78
78
-
home = cfg.dataDir;
79
79
-
createHome = true;
80
80
-
shell = pkgs.bash;
81
81
-
};
82
82
-
83
83
-
users.groups.cedarlogic = {};
84
84
-
85
85
-
# Caddy needs to read static files from the dist directory
86
86
-
users.users.caddy.extraGroups = [ "cedarlogic" "services" ];
87
87
-
88
88
-
# Allow cedarlogic user to restart its own service (for SSH deploys)
89
89
-
security.sudo.extraRules = [
90
90
-
{
91
91
-
users = [ "cedarlogic" ];
92
92
-
commands = [
93
93
-
{
94
94
-
command = "/run/current-system/sw/bin/systemctl restart cedarlogic.service";
95
95
-
options = [ "NOPASSWD" ];
96
96
-
}
97
97
-
];
98
98
-
}
99
99
-
];
48
48
+
# Disable default caddy — we need path-based routing to 3 backends
49
49
+
atelier.services.cedarlogic.caddy.enable = false;
100
50
101
101
-
# Systemd service
102
102
-
systemd.services.cedarlogic = {
103
103
-
description = "CedarLogic circuit simulator";
104
104
-
wantedBy = [ "multi-user.target" ];
105
105
-
after = [ "network.target" ];
106
106
-
path = [ pkgs.git pkgs.openssh pkgs.unstable.bun ];
51
51
+
# Data declarations for automatic backup
52
52
+
atelier.services.cedarlogic.data = {
53
53
+
sqlite = "${cfg.dataDir}/data/cedarlogic.db";
54
54
+
};
107
55
108
108
-
preStart = ''
109
109
-
set -e
56
56
+
# Caddy needs to read static files from the dist directory
57
57
+
users.users.caddy.extraGroups = [ "cedarlogic" "services" ];
110
58
111
111
-
# Clone if not present
112
112
-
if [ ! -d ${appDir}/.git ]; then
113
113
-
${pkgs.git}/bin/git clone -b ${cfg.deploy.branch} ${cfg.deploy.repository} ${appDir}
114
114
-
fi
59
59
+
# Longer timeout for Vite build
60
60
+
systemd.services.cedarlogic.serviceConfig = {
61
61
+
TimeoutStartSec = lib.mkForce "120s";
62
62
+
UMask = "0022";
63
63
+
};
115
64
65
65
+
# Build step: install deps + parse gates + vite build
66
66
+
systemd.services.cedarlogic.preStart = lib.mkAfter ''
116
67
cd ${webDir}
117
68
118
118
-
# Install dependencies
119
69
if [ -f package.json ]; then
120
70
${pkgs.unstable.bun}/bin/bun install
121
71
fi
···
127
77
${pkgs.unstable.bun}/bin/bun run build
128
78
'';
129
79
130
130
-
serviceConfig = {
131
131
-
Type = "exec";
132
132
-
User = "cedarlogic";
133
133
-
Group = "cedarlogic";
134
134
-
# Don't set WorkingDirectory — preStart needs to run before
135
135
-
# the repo is cloned, and systemd applies it to all stages.
136
136
-
# Instead, ExecStart cd's into webDir.
137
137
-
EnvironmentFile = lib.mkIf (cfg.secretsFile != null) cfg.secretsFile;
138
138
-
Environment = [
139
139
-
"NODE_ENV=production"
140
140
-
"PORT=${toString cfg.port}"
141
141
-
"WS_PORT=${toString cfg.wsPort}"
142
142
-
"CURSOR_PORT=${toString cfg.cursorPort}"
143
143
-
"DATABASE_PATH=${cfg.dataDir}/data/cedarlogic.db"
144
144
-
"GOOGLE_REDIRECT_URI=https://${cfg.domain}/auth/google/callback"
145
145
-
];
146
146
-
ExecStart = "${pkgs.bash}/bin/bash -c 'cd ${webDir} && exec ${pkgs.unstable.bun}/bin/bun run src/server/index.ts'";
147
147
-
Restart = "on-failure";
148
148
-
RestartSec = "10s";
149
149
-
TimeoutStartSec = "120s";
150
150
-
151
151
-
StateDirectory = "cedarlogic";
152
152
-
StateDirectoryMode = "0755";
153
153
-
154
154
-
UMask = "0022";
155
155
-
NoNewPrivileges = true;
156
156
-
ProtectSystem = "strict";
157
157
-
ProtectHome = true;
158
158
-
PrivateTmp = true;
159
159
-
};
160
160
-
161
161
-
serviceConfig.ExecStartPre = [
162
162
-
"!${pkgs.writeShellScript "cedarlogic-setup" ''
163
163
-
mkdir -p ${webDir}
164
164
-
mkdir -p ${cfg.dataDir}/data
165
165
-
chown -R cedarlogic:services ${cfg.dataDir}
166
166
-
chmod -R g+rwX ${cfg.dataDir}
167
167
-
''}"
168
168
-
];
169
169
-
};
170
170
-
171
171
-
systemd.tmpfiles.rules = [
172
172
-
"d ${appDir} 0755 cedarlogic services -"
173
173
-
"d ${cfg.dataDir}/data 0755 cedarlogic services -"
174
174
-
];
175
175
-
176
176
-
# Caddy - path-based routing to 3 backends + static file serving
177
177
-
services.caddy.virtualHosts.${cfg.domain} = {
178
178
-
extraConfig = ''
179
179
-
tls {
180
180
-
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
181
181
-
}
182
182
-
header {
183
183
-
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
184
184
-
}
80
80
+
# Caddy - path-based routing to 3 backends + static file serving
81
81
+
services.caddy.virtualHosts.${cfg.domain} = {
82
82
+
extraConfig = ''
83
83
+
tls {
84
84
+
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
85
85
+
}
86
86
+
header {
87
87
+
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
88
88
+
}
185
89
186
186
-
# Hocuspocus WebSocket (Yjs collaboration)
187
187
-
handle /ws {
188
188
-
reverse_proxy localhost:${toString cfg.wsPort}
189
189
-
}
90
90
+
# Hocuspocus WebSocket (Yjs collaboration)
91
91
+
handle /ws {
92
92
+
reverse_proxy localhost:${toString cfg.wsPort}
93
93
+
}
190
94
191
191
-
# Cursor relay WebSocket
192
192
-
handle /cursor-ws {
193
193
-
reverse_proxy localhost:${toString cfg.cursorPort}
194
194
-
}
95
95
+
# Cursor relay WebSocket
96
96
+
handle /cursor-ws {
97
97
+
reverse_proxy localhost:${toString cfg.cursorPort}
98
98
+
}
195
99
196
196
-
# API and auth routes
197
197
-
handle /api/* {
198
198
-
reverse_proxy localhost:${toString cfg.port}
199
199
-
}
200
200
-
handle /auth/* {
201
201
-
reverse_proxy localhost:${toString cfg.port}
202
202
-
}
100
100
+
# API and auth routes
101
101
+
handle /api/* {
102
102
+
reverse_proxy localhost:${toString cfg.port}
103
103
+
}
104
104
+
handle /auth/* {
105
105
+
reverse_proxy localhost:${toString cfg.port}
106
106
+
}
203
107
204
204
-
# Static files (Vite build output + WASM)
205
205
-
handle {
206
206
-
root * ${webDir}/dist
207
207
-
try_files {path} /index.html
208
208
-
file_server
209
209
-
}
210
210
-
'';
108
108
+
# Static files (Vite build output + WASM)
109
109
+
handle {
110
110
+
root * ${webDir}/dist
111
111
+
try_files {path} /index.html
112
112
+
file_server
113
113
+
}
114
114
+
'';
115
115
+
};
211
116
};
117
117
+
};
118
118
+
in
119
119
+
{
120
120
+
imports = [ baseModule ];
212
121
213
213
-
# Backup config
214
214
-
atelier.backup.services.cedarlogic = {
215
215
-
paths = [ "${cfg.dataDir}/data" ];
216
216
-
exclude = [ "*.log" ];
217
217
-
preBackup = "systemctl stop cedarlogic";
218
218
-
postBackup = "systemctl start cedarlogic";
219
219
-
};
122
122
+
# Override the initial clone to use the non-default branch
123
123
+
config = lib.mkIf cfg.enable {
124
124
+
systemd.services.cedarlogic.preStart = lib.mkBefore ''
125
125
+
set -e
126
126
+
if [ ! -d ${cfg.dataDir}/app/.git ]; then
127
127
+
${pkgs.git}/bin/git clone -b ${cfg.branch} ${cfg.repository} ${cfg.dataDir}/app
128
128
+
fi
129
129
+
'';
220
130
};
221
131
}