main
1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.wallabag;
12
13 defaultUser = "wallabag";
14 defaultGroup = "wallabag";
15
16 # Generate parameters.yml for Symfony
17 parametersYml = pkgs.writeText "wallabag-parameters.yml" ''
18 parameters:
19 database_driver: pdo_pgsql
20 database_host: ${cfg.database.host}
21 database_port: ${toString cfg.database.port}
22 database_name: ${cfg.database.name}
23 database_user: ${cfg.database.user}
24 database_password: ~
25 database_path: null
26 database_table_prefix: wallabag_
27 database_socket: null
28 database_charset: utf8
29
30 domain_name: '${cfg.domainName}'
31 server_name: '${cfg.serverName}'
32
33 mailer_dsn: '${cfg.mailerDsn}'
34 locale: ${cfg.locale}
35 secret: __WALLABAG_SECRET_PLACEHOLDER__
36
37 twofactor_sender: ${cfg.twofactorSender}
38 fosuser_registration: ${boolToString cfg.registration}
39 fosuser_confirmation: ${boolToString cfg.registrationConfirmation}
40
41 fos_oauth_server_access_token_lifetime: 3600
42 fos_oauth_server_refresh_token_lifetime: 1209600
43
44 from_email: ${cfg.fromEmail}
45 rss_limit: 50
46
47 rabbitmq_host: localhost
48 rabbitmq_port: 5672
49 rabbitmq_user: guest
50 rabbitmq_password: guest
51 rabbitmq_prefetch_count: 10
52
53 redis_scheme: tcp
54 redis_host: localhost
55 redis_port: 6379
56 redis_path: null
57 redis_password: null
58
59 sentry_dsn: null
60 '';
61
62 # Setup script that initializes the data directory and runs migrations
63 wallabag-setup = pkgs.writeShellScript "wallabag-setup" ''
64 export PATH="${phpPackage}/bin:$PATH"
65 set -euo pipefail
66
67 DATA_DIR="${cfg.dataDir}"
68 PACKAGE="${cfg.package}"
69
70 # Ensure data directory structure
71 mkdir -p "$DATA_DIR/app/config"
72 mkdir -p "$DATA_DIR/var/cache/prod"
73 mkdir -p "$DATA_DIR/var/logs"
74 mkdir -p "$DATA_DIR/var/sessions"
75 mkdir -p "$DATA_DIR/web/uploads"
76 mkdir -p "$DATA_DIR/data/db"
77 mkdir -p "$DATA_DIR/data/assets"
78
79 # Symlink all package top-level entries into data dir, skipping
80 # directories we manage (app, var, web/uploads)
81 for entry in "$PACKAGE"/*; do
82 name=$(basename "$entry")
83 case "$name" in app|var|web) continue ;; esac
84 ln -sfn "$entry" "$DATA_DIR/$name"
85 done
86
87 # Symlink app-level entries except config (which we manage)
88 for entry in "$PACKAGE"/app/*; do
89 name=$(basename "$entry")
90 [ "$name" = "config" ] && continue
91 ln -sfn "$entry" "$DATA_DIR/app/$name"
92 done
93
94 # Symlink config files except parameters.yml (which we generate)
95 for f in "$PACKAGE"/app/config/*; do
96 fname=$(basename "$f")
97 [ "$fname" = "parameters.yml" ] && continue
98 [ "$fname" = "parameters.yml.dist" ] && continue
99 ln -sfn "$f" "$DATA_DIR/app/config/$fname"
100 done
101
102 # Copy parameters.yml (install with write permission since we patch it)
103 install -m 0640 ${parametersYml} "$DATA_DIR/app/config/parameters.yml"
104
105 # Read database password from file if provided
106 DB_PASS=""
107 if [ -n "${toString cfg.database.passwordFile}" ] && [ -f "${toString cfg.database.passwordFile}" ]; then
108 DB_PASS=$(cat "${toString cfg.database.passwordFile}")
109 fi
110
111 # Read secret from file
112 WALLABAG_SECRET_VALUE=""
113 if [ -f "${toString cfg.secretKeyFile}" ]; then
114 WALLABAG_SECRET_VALUE=$(cat "${toString cfg.secretKeyFile}")
115 else
116 echo "ERROR: Secret key file not found: ${toString cfg.secretKeyFile}"
117 exit 1
118 fi
119
120 # Patch password into parameters.yml if using password auth
121 if [ -n "$DB_PASS" ]; then
122 ${pkgs.gnused}/bin/sed -i "s|database_password: ~|database_password: '$DB_PASS'|" \
123 "$DATA_DIR/app/config/parameters.yml"
124 fi
125
126 export WALLABAG_DATA="$DATA_DIR"
127 # Patch secret into parameters.yml
128 ${pkgs.gnused}/bin/sed -i "s|__WALLABAG_SECRET_PLACEHOLDER__|$WALLABAG_SECRET_VALUE|" \
129 "$DATA_DIR/app/config/parameters.yml"
130
131 # Clear cache on every start (required after upgrades)
132 rm -rf "$DATA_DIR/var/cache/prod/"*
133
134 # Run database migrations
135 ${cfg.package}/bin/console doctrine:migrations:migrate --no-interaction --env=prod || true
136
137 # Install craue settings if first run
138 ${cfg.package}/bin/console wallabag:install --env=prod --no-interaction 2>/dev/null || true
139 '';
140
141 phpPackage = pkgs.php83.withExtensions (
142 { enabled, all }:
143 (builtins.filter (e: e.pname or "" != "php-opcache") enabled)
144 ++ [
145 all.pdo_pgsql
146 all.pgsql
147 all.intl
148 all.gd
149 all.tidy
150 ]
151 );
152
153in
154{
155 options.services.wallabag = {
156 enable = mkEnableOption "wallabag read-it-later service";
157
158 package = mkPackageOption pkgs "wallabag" { };
159
160 user = mkOption {
161 type = types.str;
162 default = defaultUser;
163 description = "User to run wallabag as";
164 };
165
166 group = mkOption {
167 type = types.str;
168 default = defaultGroup;
169 description = "Group to run wallabag as";
170 };
171
172 dataDir = mkOption {
173 type = types.path;
174 default = "/var/lib/wallabag";
175 description = "Data directory for wallabag";
176 };
177
178 domainName = mkOption {
179 type = types.str;
180 example = "https://wallabag.example.com";
181 description = "The full URL of your wallabag instance";
182 };
183
184 serverName = mkOption {
185 type = types.str;
186 default = "wallabag";
187 description = "Display name for the wallabag instance";
188 };
189
190 locale = mkOption {
191 type = types.str;
192 default = "en";
193 description = "Default locale";
194 };
195
196 mailerDsn = mkOption {
197 type = types.str;
198 default = "smtp://127.0.0.1";
199 description = "Mailer DSN for sending emails";
200 };
201
202 fromEmail = mkOption {
203 type = types.str;
204 default = "no-reply@wallabag.org";
205 description = "From email address";
206 };
207
208 twofactorSender = mkOption {
209 type = types.str;
210 default = "no-reply@wallabag.org";
211 description = "Two-factor authentication sender email";
212 };
213
214 registration = mkOption {
215 type = types.bool;
216 default = false;
217 description = "Allow user registration";
218 };
219
220 registrationConfirmation = mkOption {
221 type = types.bool;
222 default = false;
223 description = "Require email confirmation for registration";
224 };
225
226 secretKeyFile = mkOption {
227 type = types.path;
228 description = "Path to file containing the Symfony secret key";
229 };
230
231 port = mkOption {
232 type = types.port;
233 default = 8084;
234 description = "Port for the wallabag PHP-FPM FastCGI server (used with a reverse proxy)";
235 };
236
237 database = {
238 createLocally = mkOption {
239 type = types.bool;
240 default = true;
241 description = "Whether to create the PostgreSQL database locally";
242 };
243
244 host = mkOption {
245 type = types.str;
246 default = "/run/postgresql";
247 description = "Database host (use socket path for local peer auth)";
248 };
249
250 port = mkOption {
251 type = types.port;
252 default = 5432;
253 description = "Database port";
254 };
255
256 name = mkOption {
257 type = types.str;
258 default = "wallabag";
259 description = "Database name";
260 };
261
262 user = mkOption {
263 type = types.str;
264 default = "wallabag";
265 description = "Database user";
266 };
267
268 passwordFile = mkOption {
269 type = types.nullOr types.path;
270 default = null;
271 description = "Path to file containing the database password (null for peer auth)";
272 };
273 };
274
275 poolConfig = mkOption {
276 type = types.attrsOf (
277 types.oneOf [
278 types.str
279 types.int
280 types.bool
281 ]
282 );
283 default = { };
284 description = "Additional PHP-FPM pool configuration";
285 };
286 };
287
288 config = mkIf cfg.enable {
289 services.postgresql = mkIf cfg.database.createLocally {
290 enable = true;
291 ensureDatabases = [ cfg.database.name ];
292 ensureUsers = [
293 {
294 name = cfg.database.user;
295 ensureDBOwnership = true;
296 }
297 ];
298 };
299
300 # PHP-FPM pool for wallabag
301 services.phpfpm.pools.wallabag = {
302 user = cfg.user;
303 group = cfg.group;
304 phpPackage = phpPackage;
305 phpOptions = ''
306 log_errors = on
307 post_max_size = 20M
308 upload_max_filesize = 20M
309 memory_limit = 256M
310 '';
311 phpEnv = {
312 WALLABAG_DATA = cfg.dataDir;
313 };
314 settings = {
315 "listen.mode" = mkDefault "0660";
316 "listen.owner" = mkDefault cfg.user;
317 "listen.group" = mkDefault config.services.caddy.group;
318 "pm" = mkDefault "dynamic";
319 "pm.max_children" = mkDefault 10;
320 "pm.start_servers" = mkDefault 2;
321 "pm.min_spare_servers" = mkDefault 1;
322 "pm.max_spare_servers" = mkDefault 4;
323 "pm.max_requests" = mkDefault 500;
324 }
325 // cfg.poolConfig;
326 };
327
328 # Caddy as lightweight reverse proxy (PHP-FPM → HTTP)
329 services.caddy = {
330 enable = true;
331 virtualHosts.":${toString cfg.port}" = {
332 extraConfig = ''
333 root * ${cfg.package}/web
334 php_fastcgi unix/${config.services.phpfpm.pools.wallabag.socket} {
335 split .php
336 index app.php
337 env WALLABAG_DATA ${cfg.dataDir}
338 }
339 file_server
340 '';
341 };
342 };
343
344 # Setup service that runs before PHP-FPM
345 systemd.services.wallabag-setup = {
346 description = "Wallabag setup (migrations, cache clear)";
347 after = [ "postgresql.service" ];
348 requires = mkIf cfg.database.createLocally [ "postgresql.service" ];
349 requiredBy = [ "phpfpm-wallabag.service" ];
350 before = [ "phpfpm-wallabag.service" ];
351 serviceConfig = {
352 Type = "oneshot";
353 ExecStart = wallabag-setup;
354 User = cfg.user;
355 Group = cfg.group;
356 RemainAfterExit = true;
357 ReadWritePaths = [ cfg.dataDir ];
358 Environment = "PATH=${phpPackage}/bin:${pkgs.coreutils}/bin";
359 WorkingDirectory = cfg.package;
360 };
361 restartTriggers = [
362 cfg.package
363 parametersYml
364 ];
365 };
366
367 # Create data directories
368 systemd.tmpfiles.settings."10-wallabag" =
369 let
370 defaultConfig = {
371 user = cfg.user;
372 group = cfg.group;
373 mode = "0750";
374 };
375 in
376 {
377 "${cfg.dataDir}".d = defaultConfig;
378 "${cfg.dataDir}/app".d = defaultConfig;
379 "${cfg.dataDir}/app/config".d = defaultConfig;
380 "${cfg.dataDir}/var".d = defaultConfig;
381 "${cfg.dataDir}/var/cache".d = defaultConfig;
382 "${cfg.dataDir}/var/logs".d = defaultConfig;
383 "${cfg.dataDir}/var/sessions".d = defaultConfig;
384 "${cfg.dataDir}/web".d = defaultConfig;
385 "${cfg.dataDir}/web/uploads".d = defaultConfig;
386 "${cfg.dataDir}/data".d = defaultConfig;
387 "${cfg.dataDir}/data/db".d = defaultConfig;
388 "${cfg.dataDir}/data/assets".d = defaultConfig;
389 };
390
391 users.users = mkIf (cfg.user == defaultUser) {
392 wallabag = {
393 inherit (cfg) group;
394 isSystemUser = true;
395 home = cfg.dataDir;
396 };
397 };
398
399 users.groups = mkIf (cfg.group == defaultGroup) {
400 wallabag = { };
401 };
402
403 # Open firewall port
404 networking.firewall.allowedTCPPorts = [ cfg.port ];
405 };
406}