1{
2 lib,
3 pkgs,
4 config,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.plausible;
12
13in
14{
15 options.services.plausible = {
16 enable = mkEnableOption "plausible";
17
18 package = mkPackageOption pkgs "plausible" { };
19
20 database = {
21 clickhouse = {
22 setup = mkEnableOption "creating a clickhouse instance" // {
23 default = true;
24 };
25 url = mkOption {
26 default = "http://localhost:8123/default";
27 type = types.str;
28 description = ''
29 The URL to be used to connect to `clickhouse`.
30 '';
31 };
32 };
33 postgres = {
34 setup = mkEnableOption "creating a postgresql instance" // {
35 default = true;
36 };
37 dbname = mkOption {
38 default = "plausible";
39 type = types.str;
40 description = ''
41 Name of the database to use.
42 '';
43 };
44 socket = mkOption {
45 default = "/run/postgresql";
46 type = types.str;
47 description = ''
48 Path to the UNIX domain-socket to communicate with `postgres`.
49 '';
50 };
51 };
52 };
53
54 server = {
55 disableRegistration = mkOption {
56 default = true;
57 type = types.enum [
58 true
59 false
60 "invite_only"
61 ];
62 description = ''
63 Whether to prohibit creating an account in plausible's UI or allow on `invite_only`.
64 '';
65 };
66 secretKeybaseFile = mkOption {
67 type = types.either types.path types.str;
68 description = ''
69 Path to the secret used by the `phoenix`-framework. Instructions
70 how to generate one are documented in the
71 [framework docs](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Secret.html#content).
72 '';
73 };
74 listenAddress = mkOption {
75 default = "127.0.0.1";
76 type = types.str;
77 description = ''
78 The IP address on which the server is listening.
79 '';
80 };
81 port = mkOption {
82 default = 8000;
83 type = types.port;
84 description = ''
85 Port where the service should be available.
86 '';
87 };
88 baseUrl = mkOption {
89 type = types.str;
90 description = ''
91 Public URL where plausible is available.
92
93 Note that `/path` components are currently ignored:
94 <https://github.com/plausible/analytics/issues/1182>.
95 '';
96 };
97 };
98
99 mail = {
100 email = mkOption {
101 default = "hello@plausible.local";
102 type = types.str;
103 description = ''
104 The email id to use for as *from* address of all communications
105 from Plausible.
106 '';
107 };
108 smtp = {
109 hostAddr = mkOption {
110 default = "localhost";
111 type = types.str;
112 description = ''
113 The host address of your smtp server.
114 '';
115 };
116 hostPort = mkOption {
117 default = 25;
118 type = types.port;
119 description = ''
120 The port of your smtp server.
121 '';
122 };
123 user = mkOption {
124 default = null;
125 type = types.nullOr types.str;
126 description = ''
127 The username/email in case SMTP auth is enabled.
128 '';
129 };
130 passwordFile = mkOption {
131 default = null;
132 type = with types; nullOr (either str path);
133 description = ''
134 The path to the file with the password in case SMTP auth is enabled.
135 '';
136 };
137 enableSSL = mkEnableOption "SSL when connecting to the SMTP server";
138 retries = mkOption {
139 type = types.ints.unsigned;
140 default = 2;
141 description = ''
142 Number of retries to make until mailer gives up.
143 '';
144 };
145 };
146 };
147 };
148
149 imports = [
150 (mkRemovedOptionModule [ "services" "plausible" "releaseCookiePath" ]
151 "Plausible uses no distributed Erlang features, so this option is no longer necessary and was removed"
152 )
153 (mkRemovedOptionModule [
154 "services"
155 "plausible"
156 "adminUser"
157 "name"
158 ] "Admin user is now created using first start wizard")
159 (mkRemovedOptionModule [
160 "services"
161 "plausible"
162 "adminUser"
163 "email"
164 ] "Admin user is now created using first start wizard")
165 (mkRemovedOptionModule [
166 "services"
167 "plausible"
168 "adminUser"
169 "passwordFile"
170 ] "Admin user is now created using first start wizard")
171 (mkRemovedOptionModule [
172 "services"
173 "plausible"
174 "adminUser"
175 "activate"
176 ] "Admin user is now created using first start wizard")
177 ];
178
179 config = mkIf cfg.enable {
180 services.postgresql = mkIf cfg.database.postgres.setup {
181 enable = true;
182 };
183
184 services.clickhouse = mkIf cfg.database.clickhouse.setup {
185 enable = true;
186 };
187
188 environment.systemPackages = [ cfg.package ];
189
190 systemd.services = mkMerge [
191 {
192 plausible = {
193 inherit (cfg.package.meta) description;
194 documentation = [ "https://plausible.io/docs/self-hosting" ];
195 wantedBy = [ "multi-user.target" ];
196 after =
197 optional cfg.database.clickhouse.setup "clickhouse.service"
198 ++ optionals cfg.database.postgres.setup [
199 "postgresql.service"
200 "plausible-postgres.service"
201 ];
202 requires =
203 optional cfg.database.clickhouse.setup "clickhouse.service"
204 ++ optionals cfg.database.postgres.setup [
205 "postgresql.service"
206 "plausible-postgres.service"
207 ];
208
209 environment =
210 {
211 # NixOS specific option to avoid that it's trying to write into its store-path.
212 # See also https://github.com/lau/tzdata#data-directory-and-releases
213 STORAGE_DIR = "/var/lib/plausible/elixir_tzdata";
214
215 # Configuration options from
216 # https://plausible.io/docs/self-hosting-configuration
217 PORT = toString cfg.server.port;
218 LISTEN_IP = cfg.server.listenAddress;
219
220 # Note [plausible-needs-no-erlang-distributed-features]:
221 # Plausible does not use, and does not plan to use, any of
222 # Erlang's distributed features, see:
223 # https://github.com/plausible/analytics/pull/1190#issuecomment-1018820934
224 # Thus, disable distribution for improved simplicity and security:
225 #
226 # When distribution is enabled,
227 # Elixir spwans the Erlang VM, which will listen by default on all
228 # interfaces for messages between Erlang nodes (capable of
229 # remote code execution); it can be protected by a cookie; see
230 # https://erlang.org/doc/reference_manual/distributed.html#security).
231 #
232 # It would be possible to restrict the interface to one of our choice
233 # (e.g. localhost or a VPN IP) similar to how we do it with `listenAddress`
234 # for the Plausible web server; if distribution is ever needed in the future,
235 # https://github.com/NixOS/nixpkgs/pull/130297 shows how to do it.
236 #
237 # But since Plausible does not use this feature in any way,
238 # we just disable it.
239 RELEASE_DISTRIBUTION = "none";
240 # Additional safeguard, in case `RELEASE_DISTRIBUTION=none` ever
241 # stops disabling the start of EPMD.
242 ERL_EPMD_ADDRESS = "127.0.0.1";
243
244 DISABLE_REGISTRATION =
245 if isBool cfg.server.disableRegistration then
246 boolToString cfg.server.disableRegistration
247 else
248 cfg.server.disableRegistration;
249
250 RELEASE_TMP = "/var/lib/plausible/tmp";
251 # Home is needed to connect to the node with iex
252 HOME = "/var/lib/plausible";
253
254 DATABASE_URL = "postgresql:///${cfg.database.postgres.dbname}?host=${cfg.database.postgres.socket}";
255 CLICKHOUSE_DATABASE_URL = cfg.database.clickhouse.url;
256
257 BASE_URL = cfg.server.baseUrl;
258
259 MAILER_EMAIL = cfg.mail.email;
260 SMTP_HOST_ADDR = cfg.mail.smtp.hostAddr;
261 SMTP_HOST_PORT = toString cfg.mail.smtp.hostPort;
262 SMTP_RETRIES = toString cfg.mail.smtp.retries;
263 SMTP_HOST_SSL_ENABLED = boolToString cfg.mail.smtp.enableSSL;
264
265 SELFHOST = "true";
266 }
267 // (optionalAttrs (cfg.mail.smtp.user != null) {
268 SMTP_USER_NAME = cfg.mail.smtp.user;
269 });
270
271 path = [ cfg.package ] ++ optional cfg.database.postgres.setup config.services.postgresql.package;
272 script = ''
273 # Elixir does not start up if `RELEASE_COOKIE` is not set,
274 # even though we set `RELEASE_DISTRIBUTION=none` so the cookie should be unused.
275 # Thus, make a random one, which should then be ignored.
276 export RELEASE_COOKIE=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 20)
277 export SECRET_KEY_BASE="$(< $CREDENTIALS_DIRECTORY/SECRET_KEY_BASE )"
278
279 ${lib.optionalString (
280 cfg.mail.smtp.passwordFile != null
281 ) ''export SMTP_USER_PWD="$(< $CREDENTIALS_DIRECTORY/SMTP_USER_PWD )"''}
282
283 ${lib.optionalString cfg.database.postgres.setup ''
284 # setup
285 ${cfg.package}/createdb.sh
286 ''}
287
288 ${cfg.package}/migrate.sh
289 export IP_GEOLOCATION_DB=${pkgs.dbip-country-lite}/share/dbip/dbip-country-lite.mmdb
290
291 exec plausible start
292 '';
293
294 serviceConfig = {
295 DynamicUser = true;
296 PrivateTmp = true;
297 WorkingDirectory = "/var/lib/plausible";
298 StateDirectory = "plausible";
299 LoadCredential =
300 [
301 "SECRET_KEY_BASE:${cfg.server.secretKeybaseFile}"
302 ]
303 ++ lib.optionals (cfg.mail.smtp.passwordFile != null) [
304 "SMTP_USER_PWD:${cfg.mail.smtp.passwordFile}"
305 ];
306 };
307 };
308 }
309 (mkIf cfg.database.postgres.setup {
310 # `plausible' requires the `citext'-extension.
311 plausible-postgres = {
312 after = [ "postgresql.service" ];
313 partOf = [ "plausible.service" ];
314 serviceConfig = {
315 Type = "oneshot";
316 User = config.services.postgresql.superUser;
317 RemainAfterExit = true;
318 };
319 script = with cfg.database.postgres; ''
320 PSQL() {
321 ${config.services.postgresql.package}/bin/psql --port=5432 "$@"
322 }
323 # check if the database already exists
324 if ! PSQL -lqt | ${pkgs.coreutils}/bin/cut -d \| -f 1 | ${pkgs.gnugrep}/bin/grep -qw ${dbname} ; then
325 PSQL -tAc "CREATE ROLE plausible WITH LOGIN;"
326 PSQL -tAc "CREATE DATABASE ${dbname} WITH OWNER plausible;"
327 PSQL -d ${dbname} -tAc "CREATE EXTENSION IF NOT EXISTS citext;"
328 fi
329 '';
330 };
331 })
332 ];
333 };
334
335 meta.maintainers = teams.cyberus.members;
336 meta.doc = ./plausible.md;
337}