default.nix
1# Role: forge (infra.rtw.run). Personal git hosting: soft-serve behind Caddy,
2# and kilimanjaro.io, the static site pgit builds from the public repos.
3#
4# infra.rtw.run /healthz only (forge-healthz)
5# git.kilimanjaro.io soft-serve's HTTP (git smart HTTP, go-get), :23232
6# kilimanjaro.io /var/www/code, written by the post-receive hook
7# www.kilimanjaro.io redirect to kilimanjaro.io
8#
9# kilimanjaro.io and www are proxied by Cloudflare; git.kilimanjaro.io points
10# at the host.
11#
12# soft-serve SSH :23231, git daemon :9418, HTTP localhost:23232,
13# data in /var/soft/data (repos, sqlite, its SSH keys)
14# post-receive hook soft-serve/hooks/post-receive, linked into the data
15# dir; rebuilds /var/www/code in the background
16# backup daily `rclone sync` of /var/soft/data to r2:soft-serve
17# (recover.sh restores it)
18# forge-healthz every 5 minutes: disk, load, backup age, soft-serve;
19# writes the /healthz response Caddy serves
20#
21# Secrets (modules/secrets.nix): rclone.conf (per host) for R2.
22{
23 config,
24 lib,
25 pkgs,
26 pgit,
27 ...
28}:
29
30let
31 backupScript = pkgs.writeShellScriptBin "backup" (lib.fileContents ./backup.sh);
32 rcloneKey = config.servers.secrets."rclone.conf";
33
34 pgitPackage = pgit.packages.${pkgs.stdenv.hostPlatform.system}.default;
35 rebuildSite = pkgs.writeShellApplication {
36 name = "forge-rebuild-site";
37 runtimeInputs = [
38 pgitPackage
39 pkgs.sqlite
40 ];
41 text = builtins.readFile ./rebuild-site.sh;
42 };
43 softServeConfig = ./soft-serve/config.yaml;
44 healthz = pkgs.writeShellApplication {
45 name = "forge-healthz";
46 runtimeInputs = with pkgs; [
47 coreutils
48 gawk
49 jq
50 systemd
51 ];
52 text = builtins.readFile ./healthz.sh;
53 };
54
55 securityHeaders = ''
56 header {
57 # HSTS: enforce HTTPS for 2 years, include subdomains, allow preload
58 Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
59 # Prevent MIME type sniffing
60 X-Content-Type-Options "nosniff"
61 # Clickjacking protection
62 X-Frame-Options "DENY"
63 # XSS protection
64 X-XSS-Protection "1; mode=block"
65 # Remove server header
66 -Server
67 }
68 '';
69in
70{
71 servers.secrets."rclone.conf".scope = "host";
72
73 environment.systemPackages = with pkgs; [
74 soft-serve
75 rclone
76 sqlite
77 tree
78 pgitPackage
79 backupScript
80 # Called by the post-receive hook; also run it after a restore.
81 rebuildSite
82 ];
83
84 systemd.tmpfiles.rules = [
85 "d /var/soft 0755 root root -"
86 "d /var/www/code 0755 root root -"
87 ];
88
89 # Symlink hooks directory so soft-serve reads directly from /etc/soft-serve/hooks
90 # This eliminates the need for copying and avoids path unit infinite loops
91 system.activationScripts.soft-serve-hooks = ''
92 if [ -d /var/soft/data ]; then
93 # Remove old copied hooks directory if it exists (not a symlink)
94 if [ -d /var/soft/data/hooks ] && [ ! -L /var/soft/data/hooks ]; then
95 rm -rf /var/soft/data/hooks
96 fi
97
98 # Create symlink if it doesn't exist
99 if [ ! -L /var/soft/data/hooks ]; then
100 ln -sf /etc/soft-serve/hooks /var/soft/data/hooks
101 fi
102 fi
103 '';
104
105 environment.etc = {
106 "soft-serve/config.yaml".source = softServeConfig;
107 "soft-serve/hooks/post-receive" = {
108 source = ./soft-serve/hooks/post-receive;
109 mode = "0755";
110 };
111 };
112
113 systemd.services.soft-serve = {
114 description = "Soft Serve - A tasty, self-hosted Git server";
115 after = [ "network.target" ];
116 wantedBy = [ "multi-user.target" ];
117 # soft-serve reads its config only at startup.
118 restartTriggers = [ softServeConfig ];
119
120 environment = {
121 SOFT_SERVE_CONFIG_LOCATION = "/etc/soft-serve/config.yaml";
122 SOFT_SERVE_DATA_PATH = "/var/soft/data";
123 };
124
125 serviceConfig = {
126 Type = "simple";
127 WorkingDirectory = "/var/soft";
128 ExecStart = "${pkgs.soft-serve}/bin/soft serve";
129 Restart = "on-failure";
130 RestartSec = 5;
131 User = "root";
132 Group = "root";
133 };
134 };
135
136 systemd.services.backup = {
137 description = "Daily backup service";
138 wants = [ rcloneKey.unit ];
139 after = [ rcloneKey.unit ];
140 serviceConfig = {
141 Type = "oneshot";
142 ExecStart = "${backupScript}/bin/backup";
143 User = "root";
144 # last-success, for forge-healthz
145 StateDirectory = "forge-backup";
146 };
147 environment = {
148 RCLONE = "${pkgs.rclone}/bin/rclone";
149 RCLONE_CONFIG = rcloneKey.path;
150 };
151 };
152
153 systemd.timers.backup = {
154 description = "Daily backup timer (8:30 PM UTC)";
155 wantedBy = [ "timers.target" ];
156 timerConfig = {
157 OnCalendar = "20:30:00";
158 Persistent = true;
159 };
160 };
161
162 # The /healthz response, as a file for Caddy (see healthz.sh). Its output
163 # stays in /run after each run; the load samples persist in /var/lib.
164 systemd.services.forge-healthz = {
165 description = "Write the forge's /healthz response";
166 serviceConfig = {
167 Type = "oneshot";
168 ExecStart = lib.getExe healthz;
169 RuntimeDirectory = "forge-healthz";
170 RuntimeDirectoryPreserve = true;
171 StateDirectory = "forge-healthz";
172 };
173 };
174
175 systemd.timers.forge-healthz = {
176 description = "Write the forge's /healthz response (every 5 minutes)";
177 wantedBy = [ "timers.target" ];
178 timerConfig = {
179 OnBootSec = "1min";
180 OnUnitActiveSec = "5min";
181 };
182 };
183
184 # Interactive `rclone` as root (e.g. recover.sh) finds the same config. The
185 # file itself stays root-only.
186 environment.variables.RCLONE_CONFIG = rcloneKey.path;
187
188 # git SSH, git daemon, HTTP(S)
189 networking.firewall.allowedTCPPorts = [ 23231 9418 80 443 ];
190
191 services.caddy = {
192 enable = true;
193 # The old Caddyfile had no global options: Caddy's default logger at
194 # INFO (renewals show in the journal) and no access logs. Keep both.
195 logFormat = lib.mkForce "level INFO";
196
197 # ok.json -> 200, otherwise degraded.json -> 503, or 404 when neither
198 # exists yet. Never cached: the monitor must see each run's answer.
199 virtualHosts."infra.rtw.run" = {
200 logFormat = null;
201 extraConfig = ''
202 ${securityHeaders}
203 handle /healthz {
204 root * /run/forge-healthz
205 header Cache-Control "no-store"
206 @ok file /ok.json
207 handle @ok {
208 rewrite * /ok.json
209 file_server
210 }
211 handle {
212 rewrite * /degraded.json
213 file_server {
214 status 503
215 }
216 }
217 }
218 handle {
219 respond 404
220 }
221 '';
222 };
223
224 virtualHosts."git.kilimanjaro.io" = {
225 logFormat = null;
226 extraConfig = ''
227 ${securityHeaders}
228 # soft-serve's HTTP server
229 reverse_proxy localhost:23232
230 '';
231 };
232
233 virtualHosts."www.kilimanjaro.io" = {
234 logFormat = null;
235 extraConfig = ''
236 redir https://kilimanjaro.io{uri} permanent
237 '';
238 };
239
240 virtualHosts."kilimanjaro.io" = {
241 logFormat = null;
242 extraConfig = ''
243 ${securityHeaders}
244 root * /var/www/code
245 encode gzip
246
247 # Strip .html extensions for clean URLs
248 try_files {path} {path}.html {path}/ =404
249
250 file_server {
251 index index.html
252 }
253
254 # 12 hour cache for most static assets
255 @notCSS not path *.css
256 header @notCSS Cache-Control "max-age=43200"
257
258 # Immutable cache for CSS files (hashed filenames)
259 @css path *.css
260 header @css Cache-Control "max-age=31536000, immutable"
261 '';
262 };
263 };
264}