1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.immich;
9 format = pkgs.formats.json { };
10 isPostgresUnixSocket = lib.hasPrefix "/" cfg.database.host;
11 isRedisUnixSocket = lib.hasPrefix "/" cfg.redis.host;
12
13 commonServiceConfig = {
14 Type = "simple";
15 Restart = "on-failure";
16 RestartSec = 3;
17
18 # Hardening
19 CapabilityBoundingSet = "";
20 NoNewPrivileges = true;
21 PrivateUsers = true;
22 PrivateTmp = true;
23 PrivateDevices = cfg.accelerationDevices == [ ];
24 DeviceAllow = mkIf (cfg.accelerationDevices != null) cfg.accelerationDevices;
25 PrivateMounts = true;
26 ProtectClock = true;
27 ProtectControlGroups = true;
28 ProtectHome = true;
29 ProtectHostname = true;
30 ProtectKernelLogs = true;
31 ProtectKernelModules = true;
32 ProtectKernelTunables = true;
33 RestrictAddressFamilies = [
34 "AF_INET"
35 "AF_INET6"
36 "AF_UNIX"
37 ];
38 RestrictNamespaces = true;
39 RestrictRealtime = true;
40 RestrictSUIDSGID = true;
41 UMask = "0077";
42 };
43 inherit (lib)
44 types
45 mkIf
46 mkOption
47 mkEnableOption
48 ;
49in
50{
51 options.services.immich = {
52 enable = mkEnableOption "Immich";
53 package = lib.mkPackageOption pkgs "immich" { };
54
55 mediaLocation = mkOption {
56 type = types.path;
57 default = "/var/lib/immich";
58 description = "Directory used to store media files. If it is not the default, the directory has to be created manually such that the immich user is able to read and write to it.";
59 };
60 environment = mkOption {
61 type = types.submodule { freeformType = types.attrsOf types.str; };
62 default = { };
63 example = {
64 IMMICH_LOG_LEVEL = "verbose";
65 };
66 description = ''
67 Extra configuration environment variables. Refer to the [documentation](https://immich.app/docs/install/environment-variables) for options tagged with 'server', 'api' or 'microservices'.
68 '';
69 };
70 secretsFile = mkOption {
71 type = types.nullOr (
72 types.str
73 // {
74 # We don't want users to be able to pass a path literal here but
75 # it should look like a path.
76 check = it: lib.isString it && lib.types.path.check it;
77 }
78 );
79 default = null;
80 example = "/run/secrets/immich";
81 description = ''
82 Path of a file with extra environment variables to be loaded from disk. This file is not added to the nix store, so it can be used to pass secrets to immich. Refer to the [documentation](https://immich.app/docs/install/environment-variables) for options.
83
84 To set a database password set this to a file containing:
85 ```
86 DB_PASSWORD=<pass>
87 ```
88 '';
89 };
90 host = mkOption {
91 type = types.str;
92 default = "localhost";
93 description = "The host that immich will listen on.";
94 };
95 port = mkOption {
96 type = types.port;
97 default = 2283;
98 description = "The port that immich will listen on.";
99 };
100 openFirewall = mkOption {
101 type = types.bool;
102 default = false;
103 description = "Whether to open the immich port in the firewall";
104 };
105 user = mkOption {
106 type = types.str;
107 default = "immich";
108 description = "The user immich should run as.";
109 };
110 group = mkOption {
111 type = types.str;
112 default = "immich";
113 description = "The group immich should run as.";
114 };
115
116 settings = mkOption {
117 default = null;
118 description = ''
119 Configuration for Immich.
120 See <https://immich.app/docs/install/config-file/> or navigate to
121 <https://my.immich.app/admin/system-settings> for
122 options and defaults.
123 Setting it to `null` allows configuring Immich in the web interface.
124 '';
125 type = types.nullOr (
126 types.submodule {
127 freeformType = format.type;
128 options = {
129 newVersionCheck.enabled = mkOption {
130 type = types.bool;
131 default = false;
132 description = ''
133 Check for new versions.
134 This feature relies on periodic communication with github.com.
135 '';
136 };
137 server.externalDomain = mkOption {
138 type = types.str;
139 default = "";
140 description = "Domain for publicly shared links, including `http(s)://`.";
141 };
142 };
143 }
144 );
145 };
146
147 machine-learning = {
148 enable =
149 mkEnableOption "immich's machine-learning functionality to detect faces and search for objects"
150 // {
151 default = true;
152 };
153 environment = mkOption {
154 type = types.submodule { freeformType = types.attrsOf types.str; };
155 default = { };
156 example = {
157 MACHINE_LEARNING_MODEL_TTL = "600";
158 };
159 description = ''
160 Extra configuration environment variables. Refer to the [documentation](https://immich.app/docs/install/environment-variables) for options tagged with 'machine-learning'.
161 '';
162 };
163 };
164
165 accelerationDevices = mkOption {
166 type = types.nullOr (types.listOf types.str);
167 default = [ ];
168 example = [ "/dev/dri/renderD128" ];
169 description = ''
170 A list of device paths to hardware acceleration devices that immich should
171 have access to. This is useful when transcoding media files.
172 The special value `[ ]` will disallow all devices using `PrivateDevices`. `null` will give access to all devices.
173 '';
174 };
175
176 database = {
177 enable =
178 mkEnableOption "the postgresql database for use with immich. See {option}`services.postgresql`"
179 // {
180 default = true;
181 };
182 createDB = mkEnableOption "the automatic creation of the database for immich." // {
183 default = true;
184 };
185 name = mkOption {
186 type = types.str;
187 default = "immich";
188 description = "The name of the immich database.";
189 };
190 host = mkOption {
191 type = types.str;
192 default = "/run/postgresql";
193 example = "127.0.0.1";
194 description = "Hostname or address of the postgresql server. If an absolute path is given here, it will be interpreted as a unix socket path.";
195 };
196 port = mkOption {
197 type = types.port;
198 default = 5432;
199 description = "Port of the postgresql server.";
200 };
201 user = mkOption {
202 type = types.str;
203 default = "immich";
204 description = "The database user for immich.";
205 };
206 };
207 redis = {
208 enable = mkEnableOption "a redis cache for use with immich" // {
209 default = true;
210 };
211 host = mkOption {
212 type = types.str;
213 default = config.services.redis.servers.immich.unixSocket;
214 defaultText = lib.literalExpression "config.services.redis.servers.immich.unixSocket";
215 description = "The host that redis will listen on.";
216 };
217 port = mkOption {
218 type = types.port;
219 default = 0;
220 description = "The port that redis will listen on. Set to zero to disable TCP.";
221 };
222 };
223 };
224
225 config = mkIf cfg.enable {
226 assertions = [
227 {
228 assertion = !isPostgresUnixSocket -> cfg.secretsFile != null;
229 message = "A secrets file containing at least the database password must be provided when unix sockets are not used.";
230 }
231 ];
232
233 services.postgresql = mkIf cfg.database.enable {
234 enable = true;
235 ensureDatabases = mkIf cfg.database.createDB [ cfg.database.name ];
236 ensureUsers = mkIf cfg.database.createDB [
237 {
238 name = cfg.database.user;
239 ensureDBOwnership = true;
240 ensureClauses.login = true;
241 }
242 ];
243 extensions = ps: with ps; [ pgvecto-rs ];
244 settings = {
245 shared_preload_libraries = [ "vectors.so" ];
246 search_path = "\"$user\", public, vectors";
247 };
248 };
249 systemd.services.postgresql.serviceConfig.ExecStartPost =
250 let
251 sqlFile = pkgs.writeText "immich-pgvectors-setup.sql" ''
252 CREATE EXTENSION IF NOT EXISTS unaccent;
253 CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
254 CREATE EXTENSION IF NOT EXISTS vectors;
255 CREATE EXTENSION IF NOT EXISTS cube;
256 CREATE EXTENSION IF NOT EXISTS earthdistance;
257 CREATE EXTENSION IF NOT EXISTS pg_trgm;
258
259 ALTER SCHEMA public OWNER TO ${cfg.database.user};
260 ALTER SCHEMA vectors OWNER TO ${cfg.database.user};
261 GRANT SELECT ON TABLE pg_vector_index_stat TO ${cfg.database.user};
262
263 ALTER EXTENSION vectors UPDATE;
264 '';
265 in
266 [
267 ''
268 ${lib.getExe' config.services.postgresql.package "psql"} -d "${cfg.database.name}" -f "${sqlFile}"
269 ''
270 ];
271
272 services.redis.servers = mkIf cfg.redis.enable {
273 immich = {
274 enable = true;
275 port = cfg.redis.port;
276 bind = mkIf (!isRedisUnixSocket) cfg.redis.host;
277 };
278 };
279
280 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
281
282 services.immich.environment =
283 let
284 postgresEnv =
285 if isPostgresUnixSocket then
286 { DB_URL = "postgresql:///${cfg.database.name}?host=${cfg.database.host}"; }
287 else
288 {
289 DB_HOSTNAME = cfg.database.host;
290 DB_PORT = toString cfg.database.port;
291 DB_DATABASE_NAME = cfg.database.name;
292 DB_USERNAME = cfg.database.user;
293 };
294 redisEnv =
295 if isRedisUnixSocket then
296 { REDIS_SOCKET = cfg.redis.host; }
297 else
298 {
299 REDIS_PORT = toString cfg.redis.port;
300 REDIS_HOSTNAME = cfg.redis.host;
301 };
302 in
303 postgresEnv
304 // redisEnv
305 // {
306 IMMICH_HOST = cfg.host;
307 IMMICH_PORT = toString cfg.port;
308 IMMICH_MEDIA_LOCATION = cfg.mediaLocation;
309 IMMICH_MACHINE_LEARNING_URL = "http://localhost:3003";
310 }
311 // lib.optionalAttrs (cfg.settings != null) {
312 IMMICH_CONFIG_FILE = "${format.generate "immich.json" cfg.settings}";
313 };
314
315 services.immich.machine-learning.environment = {
316 MACHINE_LEARNING_WORKERS = "1";
317 MACHINE_LEARNING_WORKER_TIMEOUT = "120";
318 MACHINE_LEARNING_CACHE_FOLDER = "/var/cache/immich";
319 IMMICH_HOST = "localhost";
320 IMMICH_PORT = "3003";
321 };
322
323 systemd.slices.system-immich = {
324 description = "Immich (self-hosted photo and video backup solution) slice";
325 documentation = [ "https://immich.app/docs" ];
326 };
327
328 systemd.services.immich-server = {
329 description = "Immich backend server (Self-hosted photo and video backup solution)";
330 after = [ "network.target" ];
331 wantedBy = [ "multi-user.target" ];
332 inherit (cfg) environment;
333 path = [
334 # gzip and pg_dumpall are used by the backup service
335 pkgs.gzip
336 config.services.postgresql.package
337 ];
338
339 serviceConfig = commonServiceConfig // {
340 ExecStart = lib.getExe cfg.package;
341 EnvironmentFile = mkIf (cfg.secretsFile != null) cfg.secretsFile;
342 Slice = "system-immich.slice";
343 StateDirectory = "immich";
344 SyslogIdentifier = "immich";
345 RuntimeDirectory = "immich";
346 User = cfg.user;
347 Group = cfg.group;
348 # ensure that immich-server has permission to connect to the redis socket.
349 SupplementaryGroups = mkIf (cfg.redis.enable && isRedisUnixSocket) [
350 config.services.redis.servers.immich.group
351 ];
352 };
353 };
354
355 systemd.services.immich-machine-learning = mkIf cfg.machine-learning.enable {
356 description = "immich machine learning";
357 after = [ "network.target" ];
358 wantedBy = [ "multi-user.target" ];
359 inherit (cfg.machine-learning) environment;
360 serviceConfig = commonServiceConfig // {
361 ExecStart = lib.getExe (cfg.package.machine-learning.override { immich = cfg.package; });
362 Slice = "system-immich.slice";
363 CacheDirectory = "immich";
364 User = cfg.user;
365 Group = cfg.group;
366 };
367 };
368
369 systemd.tmpfiles.settings = {
370 immich = {
371 # Redundant to the `UMask` service config setting on new installs, but installs made in
372 # early 24.11 created world-readable media storage by default, which is a privacy risk. This
373 # fixes those installs.
374 "${cfg.mediaLocation}" = {
375 e = {
376 user = cfg.user;
377 group = cfg.group;
378 mode = "0700";
379 };
380 };
381 };
382 };
383
384 users.users = mkIf (cfg.user == "immich") {
385 immich = {
386 name = "immich";
387 group = cfg.group;
388 isSystemUser = true;
389 };
390 };
391 users.groups = mkIf (cfg.group == "immich") { immich = { }; };
392 };
393 meta.maintainers = with lib.maintainers; [ jvanbruegge ];
394}