-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathconfiguration.nix
More file actions
1077 lines (972 loc) · 52.1 KB
/
Copy pathconfiguration.nix
File metadata and controls
1077 lines (972 loc) · 52.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# NixOS configuration. Shared by all coder-nixos hosts.
# Per-host modules (hardware-configuration.nix or facter.json, optional disko,
# local.nix) live under ./hosts/<host>/; everything else lives here.
#
# Apply: sudo nixos-rebuild switch (packages/services only)
# sudo nixos-rebuild boot + sudo reboot (anything touching desktop/display stack)
#
# First-time setup and the live-USB install walkthrough are in ./README.md.
# This file expects a flake (./flake.nix) to assemble the configuration via
# nixosConfigurations.<hostname>, so `nixos-rebuild switch` should resolve
# through /etc/nixos/flake.nix (symlinked to /etc/nixos-repo/flake.nix).
#
# Per-host local.nix lives at hosts/<host>/local.nix and is gitignored.
{
config,
pkgs,
lib,
...
}:
let
coder = pkgs.callPackage ./packages/coder {
channel = "mainline";
};
coderdProvider = pkgs.callPackage ./packages/coderd-provider { };
# UID 991 is pinned so the DOCKER_HOST socket path is deterministic.
# NixOS won't change an existing user's UID live, so this must stay 991.
coderUid = 991;
# Port the Coder server listens on. Single source of truth: it sets
# CODER_HTTP_ADDRESS below and every in-box URL that targets the server
# (coder-redirect, bootstrap, reset, template sync, reaper, logstream).
coderPort = 3000;
# .terraformrc pointing terraform at the locally-packaged coderd provider.
# No network access needed during `terraform init`.
terraformrc = pkgs.writeText "terraformrc-coderd" ''
provider_installation {
filesystem_mirror {
path = "${coderdProvider}"
include = ["registry.terraform.io/coder/coderd"]
}
direct {
exclude = ["registry.terraform.io/coder/coderd"]
}
}
'';
# Session-startup launcher: open the local Coder dashboard in Firefox.
# Wired up as an XDG autostart entry (see environment.etc below) on the
# INSTALLED box only — the installer ISO disables it (installer/iso.nix), and
# its coder-redirect/coder services are off there anyway. coder-redirect binds
# :80 only AFTER it has discovered the *.try.coder.app tunnel URL and serves a
# 302 to it, so opening http://127.0.0.1 before then would just show a
# connection-refused page. Poll :80 (up to ~2 min) before launching so the
# first paint is the dashboard, not an error. `firefox` resolves from the
# session PATH (the wrapped build that programs.firefox.enable installs).
openDashboard = pkgs.writeShellScript "coder-box-open-dashboard" ''
export PATH=/run/current-system/sw/bin:$PATH
for _ in $(seq 1 60); do
if ${pkgs.curl}/bin/curl -s -o /dev/null --max-time 2 http://127.0.0.1; then
break
fi
sleep 2
done
exec firefox http://127.0.0.1
'';
in
{
# Per-host modules (hardware detection via facter, disk layout via disko,
# the host's local.nix, and any host-specific overrides) live in
# ./hosts/<host>/ and are auto-discovered by flake.nix from the directory
# listing. This shared config covers what every box has in common.
imports = [
./nixos/modules/k3s # base single-node k3s server
./nixos/modules/podman # optional: rootless Podman socket runtime (enable one of podman/sysbox)
./nixos/modules/sysbox # optional: sysbox-runc runtime (isolated Docker per workspace)
./nixos/modules/tailscale # optional Tailscale (enable in hosts/<host>/local.nix)
./nixos/modules/screenconnect # optional ScreenConnect client (enable in hosts/<host>/local.nix)
];
# ── NixOS option: SSH key sync ─────────────────────────────────────────────
# Set in hosts/<host>/local.nix: services.coder-sync-ssh-keys.githubUsers = [ "user1" ];
options.services.coder-nixos.lanIp = lib.mkOption {
type = lib.types.str;
default = "";
description = "LAN IP of this box, used for CODER_AGENT_URL and k8s hostAliases so pods resolve the hostname without relying on mDNS. Set in the host's local.nix. Leave empty to fall back to hostname-based mDNS URL.";
};
# ── NixOS option: Coder initial user ───────────────────────────────────────
# The owner account coder-init-admin.service creates on first boot. Set from
# hosts/<host>/install-answers.json by the generated default.nix, or overridden
# in local.nix (optionally wired to a secret via agenix/sops). Plain strings,
# not systemd env overrides, so credentials live in one obvious place.
options.services.coder-nixos.initialUser = {
username = lib.mkOption {
type = lib.types.str;
default = "admin";
description = "Username of the Coder initial (owner) user created on first boot.";
};
email = lib.mkOption {
type = lib.types.str;
default = "";
description = "Email of the Coder initial user. Empty skips the bootstrap and leaves the browser first-run wizard to create the user.";
};
password = lib.mkOption {
type = lib.types.str;
default = "";
description = "Initial password for the Coder initial user. Change it after first login, or point it at a secret via agenix/sops.";
};
};
options.services.coder-sync-ssh-keys.githubUsers = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "GitHub usernames whose SSH keys are fetched and written to /etc/ssh/authorized_keys.d/ on each boot.";
};
config = {
# ── Platform ──────────────────────────────────────────────────────────────
# Fallback architecture. Since flake.nix no longer hardcodes `system` in
# lib.nixosSystem, something must set nixpkgs.hostPlatform. The facter
# module (nixos/modules/hardware/facter/system.nix) and any host's
# hardware-configuration.nix set it from detected hardware at mkDefault
# priority. This must therefore sit at a WEAKER priority than mkDefault
# (mkOptionDefault = 1500 vs mkDefault = 1000) so those hardware-derived
# values win — otherwise two differing mkDefaults (e.g. this x86_64 vs an
# aarch64 facter report) collide with a "conflicting definition values"
# error. It only applies when nothing else sets the platform.
# arm64 boxes without facter can set `nixpkgs.hostPlatform = "aarch64-linux";`
# in hosts/<host>/default.nix (or local.nix).
nixpkgs.hostPlatform = lib.mkOptionDefault "x86_64-linux";
# ── Terraform: prebuilt binary, not from source ───────────────────────────
# Terraform is BSL-licensed, so cache.nixos.org does not distribute it and
# `pkgs.terraform` would compile the multi-GB Go project from source during
# `nixos-install`. On the small live-USB build environment that exhausts the
# build tmpdir ("no space left on device" while compiling terraform). Swap in
# the official statically-linked release binary (same 1.14.0 version) via an
# overlay so every pkgs.terraform consumer (coder's PATH wrapper,
# systemPackages, the template-deploy scripts) picks it up. Works on arm64 too.
nixpkgs.overlays = [
(final: _prev: {
terraform = final.callPackage ./packages/terraform-binary { };
})
];
# ── Boot ──────────────────────────────────────────────────────────────────
# Defaults assume a modern UEFI machine; host modules under ./hosts/<host>/
# can override these (e.g. set systemd-boot.enable = false and configure
# boot.loader.grub on BIOS hardware).
boot.loader.systemd-boot.enable = lib.mkDefault true;
boot.loader.efi.canTouchEfiVariables = lib.mkDefault true;
# ── Filesystem: ZFS root ───────────────────────────────────────────────────
# The standard single-disk layout (installer/bootstrap/disko-standard.nix) puts root on a
# ZFS pool ("rpool"). The kernel needs the ZFS module available at boot to
# import it; declare it here so every host that follows the standard layout
# (and the prebuilt appliance images) boots. Hosts that predate disko and
# still mount an ext4 root (e.g. coder-thinkcentre) are unaffected — ZFS
# support being present costs nothing if no ZFS pool exists.
boot.supportedFilesystems = [ "zfs" ];
# ZFS refuses to import a pool unless networking.hostId is set (it stamps
# the pool so it can't be imported on two machines at once). Derive it in
# Nix from the hostname: the first 8 hex digits of its sha256. Deterministic
# per host, and since every install host gets a unique networking.hostName
# (flake.nix injects the folder name) each box ends up with a distinct id —
# no shell-side generation needed. mkDefault so a host can still override.
networking.hostId = lib.mkDefault (
builtins.substring 0 8 (builtins.hashString "sha256" config.networking.hostName)
);
# Keep the pool healthy and SSDs happy: periodic scrub (verifies every
# block against its checksum and self-heals where possible) and weekly TRIM.
services.zfs.autoScrub.enable = lib.mkDefault true;
services.zfs.trim.enable = lib.mkDefault true;
# ── Swap ──────────────────────────────────────────────────────────────────
# No on-disk swap partition (see installer/bootstrap/disko-standard.nix). Use a
# compressed in-RAM swap device instead, sized to half of RAM.
zramSwap.enable = lib.mkDefault true;
# ── Never suspend or hibernate ──────────────────────────────────────
# The box is an always-on appliance (Coder server + k3s) reached over the
# LAN and a *.try.coder.app tunnel. Suspending or hibernating drops the
# NIC, so the machine silently falls off the network (no mDNS, no SSH,
# tunnel dies) until someone physically wakes it. The shipped image runs a
# GNOME desktop, which exposes Sleep/Hibernate actions, and a stray
# `systemctl suspend` / `systemctl hibernate` (or the matching D-Bus call)
# would do the same. Mask the suspend, hibernate, and hybrid-sleep targets
# so all of those paths become a no-op.
#
# Scope is deliberately narrow: only the "drop off the network" sleep
# states are blocked. Idle/lid/power-key handling is left at NixOS
# defaults — the single concern is the box not putting itself to sleep.
systemd.targets.suspend.enable = false;
systemd.targets.hibernate.enable = false;
systemd.targets.hybrid-sleep.enable = false;
services.logind.settings.Login = {
HandleSuspendKey = "ignore";
HandleHibernateKey = "ignore";
};
# ── Networking ────────────────────────────────────────────────────────────
# Central default hostname. Install hosts override this: flake.nix's mkHost
# injects `networking.hostName = lib.mkDefault <folder-name>` for every
# non-underscore host (so coder-thinkcentre stays coder-thinkcentre, etc.).
# Underscore-prefixed image/appliance hosts (_appliance-iso, _appliance-disk)
# get no injection and so inherit "coder-box".
#
# Priority 1250 (mkOverride) is deliberately BETWEEN mkDefault (1000) and
# mkOptionDefault (1500): it beats the option's own built-in default
# ("nixos", which nixpkgs sets at mkOptionDefault and would otherwise tie
# and error), while still losing to flake.nix's mkDefault folder-name
# injection on install hosts. A host's local.nix/default.nix can override at
# normal (100) priority or mkForce.
networking.hostName = lib.mkOverride 1250 "coder-box";
networking.networkmanager.enable = true;
# mDNS: every box reachable as <hostname>.local on the LAN
services.avahi = {
enable = true;
nssmdns4 = true;
publish = {
enable = true;
addresses = true;
workstation = true;
};
};
# ── Locale / time ─────────────────────────────────────────────────────────
time.timeZone = "America/Chicago";
i18n.defaultLocale = "en_US.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "en_US.UTF-8";
LC_IDENTIFICATION = "en_US.UTF-8";
LC_MEASUREMENT = "en_US.UTF-8";
LC_MONETARY = "en_US.UTF-8";
LC_NAME = "en_US.UTF-8";
LC_NUMERIC = "en_US.UTF-8";
LC_PAPER = "en_US.UTF-8";
LC_TELEPHONE = "en_US.UTF-8";
LC_TIME = "en_US.UTF-8";
};
# ── Desktop: GNOME ────────────────────────────────────────────────────────
# GNOME 49 (the version in the pinned nixpkgs-25.11) is Wayland-only: the
# GNOME-on-Xorg session was dropped upstream (gnome-session now advertises
# `providedSessions = [ "gnome" ]` — no `gnome-xorg`), so there is no X11
# GNOME session to fall back to. GDM runs on Wayland by default and we keep
# it that way; there is therefore no `defaultSession`/`wayland.enable`
# plumbing as the SDDM/Plasma config (KDE Plasma 6 on Xorg) had before it.
# `services.xserver.enable` is still set so XWayland is available for legacy
# X11 clients (e.g. the optional ScreenConnect agent — see screenconnect.nix).
services.xserver.enable = true;
services.displayManager.gdm.enable = true;
services.desktopManager.gnome.enable = true;
services.xserver.xkb = {
layout = "us";
variant = "";
};
# ── Skip GNOME's first-run welcome experience ─────────────────────────────
# Out of the box GNOME greets every new login with two things this appliance
# doesn't want:
# 1. gnome-tour — the "Welcome to NixOS / Take the Tour" dialog. GNOME
# Shell auto-launches it from its .desktop file on first login (it ships
# in the default GNOME package set), so the only way to suppress it is to
# drop the package via environment.gnome.excludePackages.
# 2. The Activities overview (the app/window grid) shown at session
# startup. GNOME has no gsetting to disable this, so we ship the
# "no-overview" Shell extension and enable it for the session, which
# lands you on the bare desktop instead of the overview.
# (the no-overview extension package is added to environment.systemPackages
# in the shared package list below.)
environment.gnome.excludePackages = [ pkgs.gnome-tour ];
programs.dconf.profiles.user.databases = [
{
settings = {
"org/gnome/shell".enabled-extensions = [ "no-overview@fthx" ];
# ── Pinned dash icons: match the old KDE Plasma 6 taskbar ───────────
# Plasma 6 shipped its Icons-Only Task Manager pre-pinned with three
# launchers: System Settings, the file manager (Dolphin), and the web
# browser (Firefox). Reproduce that exact set as the GNOME dash
# favourites so the box looks the same after the KDE→GNOME switch.
# GNOME's own NixOS default (Epiphany/Geary/Calendar/Music/Nautilus)
# is mostly apps we don't even install, so override it outright.
# System Settings → org.gnome.Settings (gnome-control-center)
# Dolphin → org.gnome.Nautilus (GNOME Files)
# Firefox → firefox
"org/gnome/shell".favorite-apps = [
"org.gnome.Settings.desktop"
"org.gnome.Nautilus.desktop"
"firefox.desktop"
];
};
}
];
# ── Open the Coder dashboard on login ──────────────────────────────────────
# On the installed box the coderbox user autologins into GNOME; open Firefox
# on http://127.0.0.1 (the local coder-redirect → tunnel URL) at session
# start so the dashboard is up and ready. See openDashboard in the let block
# for the wait-for-:80 logic. The installer ISO overrides this away
# (installer/iso.nix) since it has no running Coder stack.
environment.etc."xdg/autostart/coder-box-open-dashboard.desktop".text = ''
[Desktop Entry]
Type=Application
Name=Open Coder Dashboard
Comment=Open the local Coder dashboard in Firefox on login
Exec=${openDashboard}
Terminal=false
X-GNOME-Autostart-enabled=true
OnlyShowIn=GNOME;
'';
# ── Audio ─────────────────────────────────────────────────────────────────
services.pulseaudio.enable = false;
security.rtkit.enable = true;
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
};
services.printing.enable = true;
# ── Users ─────────────────────────────────────────────────────────────────
# The desktop / SSH login user is declared per-host in local.nix from the
# install-answers.json values (username + initial password chosen at install
# time). The `coder` system user (uid 991) is shared and declared further
# down.
security.sudo.wheelNeedsPassword = false;
# ── SSH ───────────────────────────────────────────────────────────────────
services.openssh = {
enable = true;
settings.PasswordAuthentication = true;
# Allow per-user files written by coder-sync-ssh-keys
extraConfig = ''
AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/authorized_keys.d/%u
'';
};
# ── SSH key sync from GitHub usernames ────────────────────────────────────
# Fetches https://github.com/<user>.keys for each username in
# services.coder-sync-ssh-keys.githubUsers (set in hosts/<host>/local.nix).
# Writes keys to /etc/ssh/authorized_keys.d/<user>. Runs at boot.
systemd.services.coder-sync-ssh-keys = {
description = "Sync SSH authorized keys from GitHub user profiles";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.writeShellScript "coder-sync-ssh-keys" ''
set -euo pipefail
KEYS_DIR="/etc/ssh/authorized_keys.d"
# User list is baked in at eval time from local.nix
USERS_CSV="${lib.concatStringsSep "," config.services.coder-sync-ssh-keys.githubUsers}"
if [ -z "$USERS_CSV" ]; then
echo "No GitHub users configured, skipping SSH key sync."
exit 0
fi
mkdir -p "$KEYS_DIR"
IFS=',' read -ra USERS <<< "$USERS_CSV"
for user in "''${USERS[@]}"; do
user="$(echo "$user" | tr -d '[:space:]')"
[ -z "$user" ] && continue
echo "Fetching SSH keys for GitHub user: $user"
keys="$(${pkgs.curl}/bin/curl -sf "https://github.com/$user.keys" || true)"
if [ -z "$keys" ]; then
echo " Warning: no keys found or fetch failed for $user"
continue
fi
echo "$keys" > "$KEYS_DIR/$user"
chmod 0644 "$KEYS_DIR/$user"
echo " Written $(echo "$keys" | wc -l) key(s) to $KEYS_DIR/$user"
done
'';
};
};
# ── Packages ──────────────────────────────────────────────────────────────
programs.firefox.enable = true;
nixpkgs.config.allowUnfree = true;
environment.systemPackages = with pkgs; [
git
vim
curl
wget
htop
jq
pciutils
usbutils
coder
terraform
gh
vlc
# GNOME Shell extension that suppresses the Activities overview shown at
# session startup (enabled via programs.dconf above). See the Desktop block.
gnomeExtensions.no-overview
];
# Use Lix (a drop-in Nix reimplementation) as the system Nix distribution
# instead of upstream CppNix. pkgs.lix comes from the pinned nixpkgs above,
# so this swaps the daemon, nixos-rebuild, and the `nix` CLI without adding
# a flake input or a from-source build.
nix.package = pkgs.lix;
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
nix.settings.download-buffer-size = 268435456; # 256 MiB; quiets the "buffer full" warning on big closure pulls
# Extra binary cache: pull community-built closures (disko and other
# nix-community derivations) instead of building them locally. `extra-`
# appends to the defaults, so cache.nixos.org stays in place.
nix.settings.extra-substituters = [ "https://nix-community.cachix.org" ];
nix.settings.extra-trusted-public-keys = [
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
];
networking.firewall.enable = false;
# ── PostgreSQL ────────────────────────────────────────────────────────────
services.postgresql = {
enable = true;
package = pkgs.postgresql;
ensureDatabases = [ "coder" ];
ensureUsers = [
{
name = "coder";
ensureDBOwnership = true;
}
];
authentication = pkgs.lib.mkOverride 10 ''
local all postgres peer
local all all peer
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
'';
};
# ── Rootless Podman ───────────────────────────────────────────────────────
# Used by Coder workspace templates via the Docker-compatible socket API.
# dockerCompat installs a `docker` shim that redirects to podman so
# workspace tooling that hard-codes `docker` (the coder-cli template, host
# debugging, ad hoc commands) still works without a real Docker daemon.
virtualisation.podman = {
enable = true;
dockerCompat = true;
extraPackages = [ pkgs.crun ]; # workaround nixpkgs#226849
};
boot.kernel.sysctl."user.max_user_namespaces" = 65536;
# sysbox is the workspace runtime for every shipped template (k3s-* pods and
# the docker-CLI sandbox). It pulls in the base k3s server. Enable by default
# so a fresh install actually has k3s running; hosts can opt out from their
# local.nix.
services.coder-nixos.sysbox.enable = lib.mkDefault true;
# ── Coder user ────────────────────────────────────────────────────────────
# UID 991 is below 1000 so isSystemUser is required (isNormalUser rejects it).
# linger = true ensures the user session (and Podman socket) starts at boot.
users.users.coder = {
isSystemUser = true;
uid = coderUid;
group = "coder";
home = "/var/lib/coder";
createHome = true;
homeMode = "700";
shell = pkgs.bash; # needed for systemd user session
linger = true;
subUidRanges = [
{
startUid = 100000;
count = 65536;
}
];
subGidRanges = [
{
startGid = 100000;
count = 65536;
}
];
};
users.groups.coder = { };
# podman.socket uses SocketGroup=podman; the NixOS podman module does not
# create this group automatically so we declare it explicitly.
users.groups.podman = { };
# /etc/coder dir + empty session-token file (populated on first boot by
# coder-init-admin.service, which runs as the coder user and needs to
# own the file to write it). The trailing `z` line re-applies the
# ownership on existing installs where the file was created under a
# previous rule that owned it root:root.
systemd.tmpfiles.rules = [
"d /etc/coder 0750 root coder -"
"f /etc/coder/session-token 0600 coder coder -"
"z /etc/coder/session-token 0600 coder coder -"
]
# Surface the agent/dev guide in each normal user's home so an agent that
# lands in $HOME (or a human on first login) finds it immediately. The repo
# itself lives at /etc/nixos-repo; agents.md documents the rebuild workflow.
# `L+` recreates the symlink on every boot so it tracks the canonical file.
++ (lib.mapAttrsToList (_: u: "L+ ${u.home}/agents.md - - - - /etc/nixos-repo/agents.md") (
lib.filterAttrs (_: u: u.isNormalUser && u.home != null) config.users.users
));
# Whitelist the root-owned baked repo so interactive `git`/`nix` as the
# login user (or via sudo) don't trip git's dubious-ownership guard with
# "repository path '/etc/nixos-repo' is not owned by current user". The
# box's own services already pass `-c safe.directory=...` inline; this is
# purely for humans/agents running git by hand. Harmless on appliance ISOs
# where /etc/nixos-repo is a read-only store path (not a git repo).
programs.git.enable = true;
programs.git.config.safe.directory = [ "/etc/nixos-repo" ];
# ── Coder server ──────────────────────────────────────────────────────────
# Base env vars live here. Server secrets (e.g. OAuth) are merged in via
# systemd.services.coder.environment in hosts/<host>/local.nix; no
# EnvironmentFile. Initial-user credentials are NOT set here — they come from
# the services.coder-nixos.initialUser option and are wired onto
# coder-init-admin.service below, so they stay off the long-running server.
systemd.services.coder = {
description = "Coder Server";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"postgresql.service"
"user@${toString coderUid}.service"
];
requires = [ "postgresql.service" ];
wants = [ "user@${toString coderUid}.service" ]; # non-fatal if user session is delayed
environment = {
CODER_HTTP_ADDRESS = "0.0.0.0:${toString coderPort}";
CODER_MAX_TOKEN_LIFETIME = "8760h"; # allow year-long tokens (e.g. nixos-sync)
CODER_MAX_ADMIN_TOKEN_LIFETIME = "8760h";
# CODER_ACCESS_URL not set → Coder auto-creates a *.try.coder.app tunnel URL
# Wildcard access URL is set automatically by the tunnel (not needed here)
# Agents (k3s pods) reach the server directly over LAN for low latency.
# This is independent of the public tunnel URL used by browsers.
CODER_AGENT_URL =
let
inherit (config.services.coder-nixos) lanIp;
in
if lanIp != "" then
"http://${lanIp}:${toString coderPort}"
else
"http://${config.networking.hostName}.local:${toString coderPort}";
CODER_PG_CONNECTION_URL = "postgres:///coder?host=/run/postgresql&user=coder&sslmode=disable";
CODER_DATA_DIR = "/var/lib/coder";
# Point the Terraform Docker provider at the rootless Podman socket.
DOCKER_HOST = "unix:///run/user/${toString coderUid}/podman/podman.sock";
# Enable all experiments: Coder AI agents, MCP, etc.
CODER_EXPERIMENTS = "*";
# Hide the AI Tasks UI from the dashboard. Experiments above enable the
# underlying features (agents, MCP); this just keeps the Tasks tab off.
CODER_HIDE_AI_TASKS = "true";
};
serviceConfig = {
ExecStart = "${coder}/bin/coder server";
User = "coder";
Group = "coder";
Restart = "on-failure";
RestartSec = "5s";
ExecStartPre = "+${pkgs.coreutils}/bin/chown -R coder:coder /var/lib/coder";
};
};
# ── Admin user bootstrap ──────────────────────────────────────────────────
# Creates the Coder initial (owner) user once from the
# services.coder-nixos.initialUser option; a sentinel prevents re-running.
# If initialUser.email is unset, skips and directs the user to the browser
# wizard.
systemd.services.coder-init-admin = {
description = "Coder bootstrap: create admin, mint session token, deploy templates";
wantedBy = [ "multi-user.target" ];
after = [ "coder.service" ];
requires = [ "coder.service" ];
# Inherit the coder.service environment so CODER_PG_CONNECTION_URL (and the
# other server vars) are available without duplication, then add the
# initial-user credentials from the services.coder-nixos.initialUser option.
environment = config.systemd.services.coder.environment // {
INITIAL_USER_USERNAME = config.services.coder-nixos.initialUser.username;
INITIAL_USER_EMAIL = config.services.coder-nixos.initialUser.email;
INITIAL_USER_PASSWORD = config.services.coder-nixos.initialUser.password;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# Runs as the coder user so peer auth against the local PG socket
# matches the 'coder' role in CODER_PG_CONNECTION_URL. The token
# file is owned by coder:coder (see tmpfiles.rules above), so this
# service can still write it.
User = "coder";
ExecStart = pkgs.writeShellScript "coder-init-admin" ''
set -euo pipefail
admin_sentinel=/var/lib/coder/.admin-created
templates_sentinel=/var/lib/coder/.templates-deployed
token_file=/etc/coder/session-token
if [ -z "''${INITIAL_USER_EMAIL:-}" ]; then
echo "INITIAL_USER_EMAIL not set, skipping bootstrap."
echo "Complete the first-run wizard at http://$(${pkgs.nettools}/bin/hostname -s).local:${toString coderPort}"
exit 0
fi
# systemd's `after = [ "coder.service" ]` only orders start, it doesn't
# wait for coder.service to be READY. coder server runs PG migrations
# before it opens its HTTP listener, so a 200 from /api/v2/buildinfo
# means the DB schema and coder role exist.
echo "Waiting for coder API..."
for i in $(seq 1 60); do
if ${pkgs.curl}/bin/curl -sf http://localhost:${toString coderPort}/api/v2/buildinfo > /dev/null 2>&1; then
echo "coder API ready after $((i * 2))s."
break
fi
if [ "$i" = 60 ]; then
echo "coder API still not responding after 120s; aborting." >&2
exit 1
fi
sleep 2
done
# 1. Create the admin user.
if [ -f "$admin_sentinel" ]; then
echo "Admin user already created."
else
echo "Creating admin user $INITIAL_USER_EMAIL..."
${coder}/bin/coder server create-admin-user \
--postgres-url "$CODER_PG_CONNECTION_URL" \
--username "$INITIAL_USER_USERNAME" \
--email "$INITIAL_USER_EMAIL" \
--password "$INITIAL_USER_PASSWORD"
touch "$admin_sentinel"
fi
# 2. Mint a long-lived session token for coder-template-sync.
mkdir -p /etc/coder
if [ -s "$token_file" ]; then
echo "Session token already exists."
else
echo "Logging in as admin to mint a long-lived token..."
SESSION=$(${pkgs.curl}/bin/curl -sf -X POST http://localhost:${toString coderPort}/api/v2/users/login \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$INITIAL_USER_EMAIL\",\"password\":\"$INITIAL_USER_PASSWORD\"}" \
| ${pkgs.jq}/bin/jq -r '.session_token')
[ -n "$SESSION" ] && [ "$SESSION" != "null" ] \
|| { echo "Admin login failed." >&2; exit 1; }
LONG_TOKEN=$(CODER_URL=http://localhost:${toString coderPort} CODER_SESSION_TOKEN="$SESSION" \
${coder}/bin/coder tokens create --name nixos-sync --lifetime 8760h)
[ -n "$LONG_TOKEN" ] \
|| { echo "Token mint failed." >&2; exit 1; }
echo "$LONG_TOKEN" > "$token_file"
chmod 600 "$token_file"
echo "Wrote session token to $token_file."
fi
# 3. Deploy templates via terraform. coder-template-sync (activation
# script) handles subsequent updates on every nixos-rebuild switch;
# this branch covers the first boot before any rebuild has run.
if [ -f "$templates_sentinel" ]; then
echo "Templates already deployed by this service."
else
echo "Deploying Coder templates..."
CODERD_SRC="/etc/nixos-repo/coderd"
STATE_DIR="/var/lib/coder/template-sync"
CODERD_DIR="$STATE_DIR/coderd-workdir"
mkdir -p "$STATE_DIR"
# /etc/nixos-repo is root-owned; this service runs as 'coder' so
# it can't write the .terraform.lock.hcl that terraform init
# creates in the working directory. Copy coderd/ into a workdir
# we own and run terraform there.
#
# On the appliance images /etc/nixos-repo is a read-only Nix store
# path (dirs 0555, files 0444), so `cp -r` reproduces those
# read-only perms and `terraform init` then fails writing
# .terraform.lock.hcl into the workdir (Permission denied) — which,
# under `set -o pipefail`, aborts this service *after* the admin
# user + token were already created, so templates silently never
# deploy. chmod -R u+w makes the copy writable. (On normal installs
# the source is already writable, so this is a harmless no-op.)
rm -rf "$CODERD_DIR"
cp -r "$CODERD_SRC" "$CODERD_DIR"
chmod -R u+w "$CODERD_DIR"
COMMIT=$(GIT_DIR=/etc/nixos-repo/.git ${pkgs.git}/bin/git -c safe.directory=/etc/nixos-repo -C /etc/nixos-repo rev-parse --short HEAD 2>/dev/null || echo "unknown")
export TF_CLI_CONFIG_FILE="${terraformrc}"
export TF_DATA_DIR="$STATE_DIR/.terraform"
${pkgs.terraform}/bin/terraform -chdir="$CODERD_DIR" init -no-color 2>&1 \
| ${pkgs.gnused}/bin/sed 's/^/[template-deploy] /'
${pkgs.terraform}/bin/terraform -chdir="$CODERD_DIR" apply -auto-approve -no-color \
-var="coder_url=http://localhost:${toString coderPort}" \
-var="coder_session_token=$(cat "$token_file")" \
-var="hostname=${config.networking.hostName}" \
-var="version_name=$COMMIT" \
-var="coder_lan_ip=${config.services.coder-nixos.lanIp}" 2>&1 \
| ${pkgs.gnused}/bin/sed 's/^/[template-deploy] /'
touch "$templates_sentinel"
echo "Templates deployed."
fi
echo "Bootstrap complete."
'';
};
};
# ── Coder reset (on-demand) ───────────────────────────────────────────────
# Tears down all workspace pods/PVCs, wipes the Coder DB and data dir,
# re-bootstraps the admin user, mints a fresh session token, and runs
# nixos-rebuild switch to push templates back to Coder — fully automated.
#
# Usage: sudo systemctl start coder-reset
systemd.services.coder-reset = {
description = "Coder – full wipe and re-bootstrap (run manually)";
# NOT in wantedBy — must be triggered explicitly with `systemctl start coder-reset`
after = [
"coder.service"
"k3s.service"
"postgresql.service"
];
requires = [ "postgresql.service" ];
# Step 8 mints a session token using the initial user's credentials, so pull
# in the coder-init-admin environment (which includes the coder.service vars
# plus the INITIAL_USER_* credentials from the initialUser option).
inherit (config.systemd.services.coder-init-admin) environment;
serviceConfig = {
Type = "oneshot";
# Run as root (needs kubectl, psql, rm -rf /var/lib/coder)
ExecStart = pkgs.writeShellScript "coder-reset" ''
set -euo pipefail
echo "=== coder-reset: starting full wipe ==="
# 1. Stop Coder + redirect so nothing re-creates state mid-wipe
echo "--- stopping coder, coder-init-admin, coder-redirect"
${pkgs.systemd}/bin/systemctl stop coder.service coder-init-admin.service coder-redirect.service || true
# 2. Delete all workspace pods and PVCs from k3s
echo "--- wiping k3s workspace pods and PVCs"
${pkgs.k3s}/bin/k3s kubectl delete pods --all -n coder-workspaces \
--force --grace-period=0 2>/dev/null || true
${pkgs.k3s}/bin/k3s kubectl delete pvc --all -n coder-workspaces \
2>/dev/null || true
# 3. Drop and recreate the Coder PostgreSQL database
echo "--- resetting PostgreSQL database"
${pkgs.sudo}/bin/sudo -u postgres ${pkgs.postgresql}/bin/psql \
-c 'DROP DATABASE IF EXISTS coder;'
${pkgs.sudo}/bin/sudo -u postgres ${pkgs.postgresql}/bin/psql \
-c 'CREATE DATABASE coder OWNER coder;'
# 4. Wipe Coder data dir (clears sentinel, tokens, provisioner state, Podman volumes)
echo "--- wiping /var/lib/coder"
${pkgs.coreutils}/bin/rm -rf /var/lib/coder/*
${pkgs.coreutils}/bin/chown -R coder:coder /var/lib/coder
# 5. Clear the session token so coder-redirect doesn't use a stale one
echo "" | ${pkgs.coreutils}/bin/tee /etc/coder/session-token > /dev/null
# 6. Restart Coder and wait for the API to become ready
echo "--- starting coder.service"
${pkgs.systemd}/bin/systemctl start coder.service
echo "--- waiting for Coder API..."
until ${pkgs.curl}/bin/curl -sf http://localhost:${toString coderPort}/api/v2/buildinfo > /dev/null 2>&1; do
sleep 3
done
# 7. Re-run admin bootstrap (sentinel was cleared in step 4)
echo "--- bootstrapping admin user"
${pkgs.systemd}/bin/systemctl start coder-init-admin.service
# 8. Mint a fresh long-lived session token using the initial user's creds
echo "--- minting session token"
SESSION=$(${pkgs.curl}/bin/curl -sf \
-X POST http://localhost:${toString coderPort}/api/v2/users/login \
-H 'Content-Type: application/json' \
-d "{\"email\":\"''${INITIAL_USER_EMAIL}\",\"password\":\"''${INITIAL_USER_PASSWORD}\"}" \
| ${pkgs.jq}/bin/jq -r '.session_token')
LONG_TOKEN=$(CODER_URL=http://localhost:${toString coderPort} CODER_SESSION_TOKEN="$SESSION" \
${coder}/bin/coder tokens create --name nixos-sync --lifetime 8760h)
echo "$LONG_TOKEN" | ${pkgs.coreutils}/bin/tee /etc/coder/session-token > /dev/null
echo "--- session token written"
# 9. Restart coder-redirect so it picks up the new token
echo "--- restarting coder-redirect"
${pkgs.systemd}/bin/systemctl restart coder-redirect.service
# 10. Re-run nixos-rebuild switch to push templates via coder-template-sync
echo "--- running nixos-rebuild switch (template sync)"
/run/current-system/sw/bin/nixos-rebuild switch \
--flake /etc/nixos-repo 2>&1 \
| ${pkgs.gnused}/bin/sed 's/^/[coder-reset] /'
echo ""
echo "=== coder-reset: complete — Coder is clean with templates restored ==="
'';
};
};
# ── Template sync activation script ──────────────────────────────────────
# Runs on every `nixos-rebuild switch`. Uses terraform-provider-coderd to
# apply templates from /etc/nixos-repo/coderd/.
# /etc/coder/session-token is populated automatically by
# coder-init-admin.service on first boot, so the skip branch below only
# triggers between nixos-install and first boot completing, or after
# coder-reset has cleared state.
system.activationScripts.coder-template-sync = {
text = ''
TOKEN_FILE="/etc/coder/session-token"
CODERD_DIR="/etc/nixos-repo/coderd"
STATE_DIR="/var/lib/coder/template-sync"
if [ ! -s "$TOKEN_FILE" ]; then
echo "[coder-template-sync] /etc/coder/session-token is empty; skipping."
echo " This file is auto-populated by coder-init-admin.service on first boot."
else
mkdir -p "$STATE_DIR"
chown coder:coder "$STATE_DIR" 2>/dev/null || true
COMMIT=$(GIT_DIR=/etc/nixos-repo/.git ${pkgs.git}/bin/git -c safe.directory=/etc/nixos-repo -C /etc/nixos-repo rev-parse --short HEAD 2>/dev/null || echo "unknown")
export TF_CLI_CONFIG_FILE="${terraformrc}"
export TF_DATA_DIR="$STATE_DIR/.terraform"
${pkgs.terraform}/bin/terraform -chdir="$CODERD_DIR" init -no-color 2>&1 \
| ${pkgs.gnused}/bin/sed 's/^/[template-sync] /' || true
${pkgs.terraform}/bin/terraform -chdir="$CODERD_DIR" apply -auto-approve -no-color \
-var="coder_url=http://localhost:${toString coderPort}" \
-var="coder_session_token=$(cat "$TOKEN_FILE")" \
-var="hostname=${config.networking.hostName}" \
-var="version_name=$COMMIT" \
-var="coder_lan_ip=${config.services.coder-nixos.lanIp}" 2>&1 \
| ${pkgs.gnused}/bin/sed 's/^/[template-sync] /'
fi
'';
deps = [ ];
};
# The nook-android image build service is host-specific and lives in
# ./hosts/coder-thinkcentre/default.nix.
# ── Coder tunnel redirect ─────────────────────────────────────────────────
# Listens on port 80 (http://coder-thinkcentre.local) and issues a 302
# redirect to the live *.try.coder.app tunnel URL, which Coder sets when
# CODER_ACCESS_URL is left unset. The shell wrapper discovers the URL and
# execs a Python HTTP server that serves the redirect.
systemd.services.coder-redirect =
let
redirectPy = pkgs.writeText "coder-redirect.py" ''
import http.server, os, sys
TUNNEL = os.environ["CODER_TUNNEL_URL"]
class R(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header("Location", TUNNEL)
self.end_headers()
do_HEAD = do_GET
def log_message(self, fmt, *args): pass
print(f"coder-redirect: serving redirects to {TUNNEL} on :80", flush=True)
http.server.HTTPServer(("", 80), R).serve_forever()
'';
in
{
description = "HTTP redirect: port 80 → Coder tunnel URL";
wantedBy = [ "multi-user.target" ];
after = [ "coder.service" ];
requires = [ "coder.service" ];
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = "10s";
ExecStart = pkgs.writeShellScript "coder-redirect" ''
set -euo pipefail
CODER_LOCAL="http://localhost:${toString coderPort}"
# write_accessUrl <text>: publish <text> to the file the login banner
# reads (see environment.interactiveShellInit below). Best-effort so
# a /tmp write can't abort the service under set -e.
write_accessUrl() {
${pkgs.coreutils}/bin/printf '%s\n' "$1" > /tmp/coder-access-url \
&& ${pkgs.coreutils}/bin/chmod 0644 /tmp/coder-access-url || true
}
# Seed with the local URL so a terminal opened before the tunnel is
# up still shows a reachable URL; upgraded to "<tunnel> (<local>)"
# below. This also overwrites any stale value from a previous run.
write_accessUrl "$CODER_LOCAL"
# Wait until the Coder API is up
echo "coder-redirect: waiting for Coder API..."
until ${pkgs.curl}/bin/curl -sf "$CODER_LOCAL/api/v2/buildinfo" > /dev/null 2>&1; do
sleep 5
done
# Fetch the tunnel URL (may take a moment to establish after startup)
TUNNEL_URL=""
for i in $(seq 1 20); do
TUNNEL_URL=$(${pkgs.curl}/bin/curl -sf \
-H "Coder-Session-Token: $(cat /etc/coder/session-token)" \
"$CODER_LOCAL/api/v2/deployment/config" \
| ${pkgs.jq}/bin/jq -r '.config.access_url // empty' 2>/dev/null || true)
if echo "$TUNNEL_URL" | grep -q "try.coder.app"; then
echo "coder-redirect: tunnel URL is $TUNNEL_URL"
break
fi
echo "coder-redirect: tunnel not ready yet (attempt $i), retrying in 5s..."
sleep 5
done
if ! echo "$TUNNEL_URL" | grep -q "try.coder.app"; then
echo "coder-redirect: could not detect tunnel URL; will retry in 30s"
sleep 30
exit 1
fi
export CODER_TUNNEL_URL="$TUNNEL_URL"
# Upgrade to "<access URL> (<local URL>)" now that the tunnel URL is
# known, so terminals show both on login.
write_accessUrl "$TUNNEL_URL ($CODER_LOCAL)"
exec ${pkgs.python3}/bin/python3 ${redirectPy}
'';
};
};
# ── Coder access URL login banner ─────────────────────────────────────────
# coder-redirect seeds /tmp/coder-access-url with the local URL and upgrades
# it to "<access URL> (<local URL>)" once the tunnel is up. Print it on
# interactive shells so both a local terminal and an SSH session show where
# to reach Coder — the old /etc/motd only surfaced on PAM logins, never in a
# desktop terminal. This only reads the file; it never touches the network.
# The exported guard keeps nested shells from reprinting it within a session.
environment.interactiveShellInit = ''
if [ -z "''${CODER_ACCESS_URL_SHOWN:-}" ] && [ -s /tmp/coder-access-url ]; then
export CODER_ACCESS_URL_SHOWN=1
printf '\n Coder is running on this box: %s\n\n' "$(cat /tmp/coder-access-url)"
fi
'';
# ── Workspace reaper ──────────────────────────────────────────────────────────
# Deletes workspaces that have been stopped for >= 72 h.
# time_til_dormant_autodelete_ms is Enterprise-only so we implement this
# ourselves: an hourly timer calls the API, finds stopped workspaces whose
# last_used_at is older than 72 h, and issues DELETE requests.
systemd.services.coder-workspace-reaper = {
description = "Delete Coder workspaces stopped for >= 72 h";
after = [ "coder.service" ];
serviceConfig = {
Type = "oneshot";
User = "root";
ExecStart = pkgs.writeShellScript "coder-workspace-reaper" ''
set -euo pipefail
CODER_LOCAL="http://localhost:${toString coderPort}"
TOKEN_FILE="/etc/coder/session-token"
DELETE_AFTER_HOURS=72
if [ ! -s "$TOKEN_FILE" ]; then
echo "coder-workspace-reaper: no session token, skipping"
exit 0
fi
TOKEN=$(cat "$TOKEN_FILE")
NOW=$(${pkgs.coreutils}/bin/date +%s)
CUTOFF=$(( NOW - DELETE_AFTER_HOURS * 3600 ))
echo "coder-workspace-reaper: checking for workspaces stopped before $(${pkgs.coreutils}/bin/date -d @$CUTOFF --iso-8601=seconds)"
WORKSPACES=$(${pkgs.curl}/bin/curl -sf \
-H "Coder-Session-Token: $TOKEN" \
"$CODER_LOCAL/api/v2/workspaces?limit=100&filterQuery=status:stopped" \
| ${pkgs.jq}/bin/jq -r '.workspaces[] | .id + " " + .name + " " + .last_used_at')
if [ -z "$WORKSPACES" ]; then
echo "coder-workspace-reaper: no stopped workspaces found"
exit 0
fi