summary refs log tree commit diff
path: root/nixos/modules/services
diff options
context:
space:
mode:
authorK900 <me@0upti.me>2022-12-27 22:30:10 +0300
committerGitHub <noreply@github.com>2022-12-27 22:30:10 +0300
commite59753591be0f411de2d1f50fec8d5c2c01c03e1 (patch)
treee84a9ebc2e60660d794edbacd03af0602f17feb5 /nixos/modules/services
parent70188cac5797bee52a0e7072af93c603f0145cf4 (diff)
parenta9601933ea41b1af21554d9a6558f950e24619f5 (diff)
downloadnixpkgs-e59753591be0f411de2d1f50fec8d5c2c01c03e1.tar
nixpkgs-e59753591be0f411de2d1f50fec8d5c2c01c03e1.tar.gz
nixpkgs-e59753591be0f411de2d1f50fec8d5c2c01c03e1.tar.bz2
nixpkgs-e59753591be0f411de2d1f50fec8d5c2c01c03e1.tar.lz
nixpkgs-e59753591be0f411de2d1f50fec8d5c2c01c03e1.tar.xz
nixpkgs-e59753591be0f411de2d1f50fec8d5c2c01c03e1.tar.zst
nixpkgs-e59753591be0f411de2d1f50fec8d5c2c01c03e1.zip
Merge pull request #192285 from illdefined/akkoma
akkoma: init at 3.5.0
Diffstat (limited to 'nixos/modules/services')
-rw-r--r--nixos/modules/services/web-apps/akkoma.md332
-rw-r--r--nixos/modules/services/web-apps/akkoma.nix1086
-rw-r--r--nixos/modules/services/web-apps/akkoma.xml396
3 files changed, 1814 insertions, 0 deletions
diff --git a/nixos/modules/services/web-apps/akkoma.md b/nixos/modules/services/web-apps/akkoma.md
new file mode 100644
index 00000000000..fc849be0c87
--- /dev/null
+++ b/nixos/modules/services/web-apps/akkoma.md
@@ -0,0 +1,332 @@
+# Akkoma {#module-services-akkoma}
+
+[Akkoma](https://akkoma.dev/) is a lightweight ActivityPub microblogging server forked from Pleroma.
+
+## Service configuration {#modules-services-akkoma-service-configuration}
+
+The Elixir configuration file required by Akkoma is generated automatically from
+[{option}`services.akkoma.config`](options.html#opt-services.akkoma.config). Secrets must be
+included from external files outside of the Nix store by setting the configuration option to
+an attribute set containing the attribute {option}`_secret` – a string pointing to the file
+containing the actual value of the option.
+
+For the mandatory configuration settings these secrets will be generated automatically if the
+referenced file does not exist during startup, unless disabled through
+[{option}`services.akkoma.initSecrets`](options.html#opt-services.akkoma.initSecrets).
+
+The following configuration binds Akkoma to the Unix socket `/run/akkoma/socket`, expecting to
+be run behind a HTTP proxy on `fediverse.example.com`.
+
+
+```nix
+services.akkoma.enable = true;
+services.akkoma.config = {
+  ":pleroma" = {
+    ":instance" = {
+      name = "My Akkoma instance";
+      description = "More detailed description";
+      email = "admin@example.com";
+      registration_open = false;
+    };
+
+    "Pleroma.Web.Endpoint" = {
+      url.host = "fediverse.example.com";
+    };
+  };
+};
+```
+
+Please refer to the [configuration cheat sheet](https://docs.akkoma.dev/stable/configuration/cheatsheet/)
+for additional configuration options.
+
+## User management {#modules-services-akkoma-user-management}
+
+After the Akkoma service is running, the administration utility can be used to
+[manage users](https://docs.akkoma.dev/stable/administration/CLI_tasks/user/). In particular an
+administrative user can be created with
+
+```ShellSession
+$ pleroma_ctl user new <nickname> <email> --admin --moderator --password <password>
+```
+
+## Proxy configuration {#modules-services-akkoma-proxy-configuration}
+
+Although it is possible to expose Akkoma directly, it is common practice to operate it behind an
+HTTP reverse proxy such as nginx.
+
+```nix
+services.akkoma.nginx = {
+  enableACME = true;
+  forceSSL = true;
+};
+
+services.nginx = {
+  enable = true;
+
+  clientMaxBodySize = "16m";
+  recommendedTlsSettings = true;
+  recommendedOptimisation = true;
+  recommendedGzipSettings = true;
+};
+```
+
+Please refer to [](#module-security-acme) for details on how to provision an SSL/TLS certificate.
+
+### Media proxy {#modules-services-akkoma-media-proxy}
+
+Without the media proxy function, Akkoma does not store any remote media like pictures or video
+locally, and clients have to fetch them directly from the source server.
+
+```nix
+# Enable nginx slice module distributed with Tengine
+services.nginx.package = pkgs.tengine;
+
+# Enable media proxy
+services.akkoma.config.":pleroma".":media_proxy" = {
+  enabled = true;
+  proxy_opts.redirect_on_failure = true;
+};
+
+# Adjust the persistent cache size as needed:
+#  Assuming an average object size of 128 KiB, around 1 MiB
+#  of memory is required for the key zone per GiB of cache.
+# Ensure that the cache directory exists and is writable by nginx.
+services.nginx.commonHttpConfig = ''
+  proxy_cache_path /var/cache/nginx/cache/akkoma-media-cache
+    levels= keys_zone=akkoma_media_cache:16m max_size=16g
+    inactive=1y use_temp_path=off;
+'';
+
+services.akkoma.nginx = {
+  locations."/proxy" = {
+    proxyPass = "http://unix:/run/akkoma/socket";
+
+    extraConfig = ''
+      proxy_cache akkoma_media_cache;
+
+      # Cache objects in slices of 1 MiB
+      slice 1m;
+      proxy_cache_key $host$uri$is_args$args$slice_range;
+      proxy_set_header Range $slice_range;
+
+      # Decouple proxy and upstream responses
+      proxy_buffering on;
+      proxy_cache_lock on;
+      proxy_ignore_client_abort on;
+
+      # Default cache times for various responses
+      proxy_cache_valid 200 1y;
+      proxy_cache_valid 206 301 304 1h;
+
+      # Allow serving of stale items
+      proxy_cache_use_stale error timeout invalid_header updating;
+    '';
+  };
+};
+```
+
+#### Prefetch remote media {#modules-services-akkoma-prefetch-remote-media}
+
+The following example enables the `MediaProxyWarmingPolicy` MRF policy which automatically
+fetches all media associated with a post through the media proxy, as soon as the post is
+received by the instance.
+
+```nix
+services.akkoma.config.":pleroma".":mrf".policies =
+  map (pkgs.formats.elixirConf { }).lib.mkRaw [
+    "Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy"
+];
+```
+
+#### Media previews {#modules-services-akkoma-media-previews}
+
+Akkoma can generate previews for media.
+
+```nix
+services.akkoma.config.":pleroma".":media_preview_proxy" = {
+  enabled = true;
+  thumbnail_max_width = 1920;
+  thumbnail_max_height = 1080;
+};
+```
+
+## Frontend management {#modules-services-akkoma-frontend-management}
+
+Akkoma will be deployed with the `pleroma-fe` and `admin-fe` frontends by default. These can be
+modified by setting
+[{option}`services.akkoma.frontends`](options.html#opt-services.akkoma.frontends).
+
+The following example overrides the primary frontend’s default configuration using a custom
+derivation.
+
+```nix
+services.akkoma.frontends.primary.package = pkgs.runCommand "pleroma-fe" {
+  config = builtins.toJSON {
+    expertLevel = 1;
+    collapseMessageWithSubject = false;
+    stopGifs = false;
+    replyVisibility = "following";
+    webPushHideIfCW = true;
+    hideScopeNotice = true;
+    renderMisskeyMarkdown = false;
+    hideSiteFavicon = true;
+    postContentType = "text/markdown";
+    showNavShortcuts = false;
+  };
+  nativeBuildInputs = with pkgs; [ jq xorg.lndir ];
+  passAsFile = [ "config" ];
+} ''
+  mkdir $out
+  lndir ${pkgs.akkoma-frontends.pleroma-fe} $out
+
+  rm $out/static/config.json
+  jq -s add ${pkgs.akkoma-frontends.pleroma-fe}/static/config.json ${config} \
+    >$out/static/config.json
+'';
+```
+
+## Federation policies {#modules-services-akkoma-federation-policies}
+
+Akkoma comes with a number of modules to police federation with other ActivityPub instances.
+The most valuable for typical users is the
+[`:mrf_simple`](https://docs.akkoma.dev/stable/configuration/cheatsheet/#mrf_simple) module
+which allows limiting federation based on instance hostnames.
+
+This configuration snippet provides an example on how these can be used. Choosing an adequate
+federation policy is not trivial and entails finding a balance between connectivity to the rest
+of the fediverse and providing a pleasant experience to the users of an instance.
+
+
+```nix
+services.akkoma.config.":pleroma" = with (pkgs.formats.elixirConf { }).lib; {
+  ":mrf".policies = map mkRaw [
+    "Pleroma.Web.ActivityPub.MRF.SimplePolicy"
+  ];
+
+  ":mrf_simple" = {
+    # Tag all media as sensitive
+    media_nsfw = mkMap {
+      "nsfw.weird.kinky" = "Untagged NSFW content";
+    };
+
+    # Reject all activities except deletes
+    reject = mkMap {
+      "kiwifarms.cc" = "Persistent harassment of users, no moderation";
+    };
+
+    # Force posts to be visible by followers only
+    followers_only = mkMap {
+      "beta.birdsite.live" = "Avoid polluting timelines with Twitter posts";
+    };
+  };
+};
+```
+
+## Upload filters {#modules-services-akkoma-upload-filters}
+
+This example strips GPS and location metadata from uploads, deduplicates them and anonymises the
+the file name.
+
+```nix
+services.akkoma.config.":pleroma"."Pleroma.Upload".filters =
+  map (pkgs.formats.elixirConf { }).lib.mkRaw [
+    "Pleroma.Upload.Filter.Exiftool"
+    "Pleroma.Upload.Filter.Dedupe"
+    "Pleroma.Upload.Filter.AnonymizeFilename"
+  ];
+```
+
+## Migration from Pleroma {#modules-services-akkoma-migration-pleroma}
+
+Pleroma instances can be migrated to Akkoma either by copying the database and upload data or by
+pointing Akkoma to the existing data. The necessary database migrations are run automatically
+during startup of the service.
+
+The configuration has to be copy‐edited manually.
+
+Depending on the size of the database, the initial migration may take a long time and exceed the
+startup timeout of the system manager. To work around this issue one may adjust the startup timeout
+{option}`systemd.services.akkoma.serviceConfig.TimeoutStartSec` or simply run the migrations
+manually:
+
+```ShellSession
+pleroma_ctl migrate
+```
+
+### Copying data {#modules-services-akkoma-migration-pleroma-copy}
+
+Copying the Pleroma data instead of re‐using it in place may permit easier reversion to Pleroma,
+but allows the two data sets to diverge.
+
+First disable Pleroma and then copy its database and upload data:
+
+```ShellSession
+# Create a copy of the database
+nix-shell -p postgresql --run 'createdb -T pleroma akkoma'
+
+# Copy upload data
+mkdir /var/lib/akkoma
+cp -R --reflink=auto /var/lib/pleroma/uploads /var/lib/akkoma/
+```
+
+After the data has been copied, enable the Akkoma service and verify that the migration has been
+successful. If no longer required, the original data may then be deleted:
+
+```ShellSession
+# Delete original database
+nix-shell -p postgresql --run 'dropdb pleroma'
+
+# Delete original Pleroma state
+rm -r /var/lib/pleroma
+```
+
+### Re‐using data {#modules-services-akkoma-migration-pleroma-reuse}
+
+To re‐use the Pleroma data in place, disable Pleroma and enable Akkoma, pointing it to the
+Pleroma database and upload directory.
+
+```nix
+# Adjust these settings according to the database name and upload directory path used by Pleroma
+services.akkoma.config.":pleroma"."Pleroma.Repo".database = "pleroma";
+services.akkoma.config.":pleroma".":instance".upload_dir = "/var/lib/pleroma/uploads";
+```
+
+Please keep in mind that after the Akkoma service has been started, any migrations applied by
+Akkoma have to be rolled back before the database can be used again with Pleroma. This can be
+achieved through `pleroma_ctl ecto.rollback`. Refer to the
+[Ecto SQL documentation](https://hexdocs.pm/ecto_sql/Mix.Tasks.Ecto.Rollback.html) for
+details.
+
+## Advanced deployment options {#modules-services-akkoma-advanced-deployment}
+
+### Confinement {#modules-services-akkoma-confinement}
+
+The Akkoma systemd service may be confined to a chroot with
+
+```nix
+services.systemd.akkoma.confinement.enable = true;
+```
+
+Confinement of services is not generally supported in NixOS and therefore disabled by default.
+Depending on the Akkoma configuration, the default confinement settings may be insufficient and
+lead to subtle errors at run time, requiring adjustment:
+
+Use
+[{option}`services.systemd.akkoma.confinement.packages`](options.html#opt-systemd.services._name_.confinement.packages)
+to make packages available in the chroot.
+
+{option}`services.systemd.akkoma.serviceConfig.BindPaths` and
+{option}`services.systemd.akkoma.serviceConfig.BindReadOnlyPaths` permit access to outside paths
+through bind mounts. Refer to
+[{manpage}`systemd.exec(5)`](https://www.freedesktop.org/software/systemd/man/systemd.exec.html#BindPaths=)
+for details.
+
+### Distributed deployment {#modules-services-akkoma-distributed-deployment}
+
+Being an Elixir application, Akkoma can be deployed in a distributed fashion.
+
+This requires setting
+[{option}`services.akkoma.dist.address`](options.html#opt-services.akkoma.dist.address) and
+[{option}`services.akkoma.dist.cookie`](options.html#opt-services.akkoma.dist.cookie). The
+specifics depend strongly on the deployment environment. For more information please check the
+relevant [Erlang documentation](https://www.erlang.org/doc/reference_manual/distributed.html).
diff --git a/nixos/modules/services/web-apps/akkoma.nix b/nixos/modules/services/web-apps/akkoma.nix
new file mode 100644
index 00000000000..47ba53e4222
--- /dev/null
+++ b/nixos/modules/services/web-apps/akkoma.nix
@@ -0,0 +1,1086 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.akkoma;
+  ex = cfg.config;
+  db = ex.":pleroma"."Pleroma.Repo";
+  web = ex.":pleroma"."Pleroma.Web.Endpoint";
+
+  isConfined = config.systemd.services.akkoma.confinement.enable;
+  hasSmtp = (attrByPath [ ":pleroma" "Pleroma.Emails.Mailer" "adapter" "value" ] null ex) == "Swoosh.Adapters.SMTP";
+
+  isAbsolutePath = v: isString v && substring 0 1 v == "/";
+  isSecret = v: isAttrs v && v ? _secret && isAbsolutePath v._secret;
+
+  absolutePath = with types; mkOptionType {
+    name = "absolutePath";
+    description = "absolute path";
+    descriptionClass = "noun";
+    check = isAbsolutePath;
+    inherit (str) merge;
+  };
+
+  secret = mkOptionType {
+    name = "secret";
+    description = "secret value";
+    descriptionClass = "noun";
+    check = isSecret;
+    nestedTypes = {
+      _secret = absolutePath;
+    };
+  };
+
+  ipAddress = with types; mkOptionType {
+    name = "ipAddress";
+    description = "IPv4 or IPv6 address";
+    descriptionClass = "conjunction";
+    check = x: str.check x && builtins.match "[.0-9:A-Fa-f]+" x != null;
+    inherit (str) merge;
+  };
+
+  elixirValue = let
+    elixirValue' = with types;
+      nullOr (oneOf [ bool int float str (attrsOf elixirValue') (listOf elixirValue') ]) // {
+        description = "Elixir value";
+      };
+  in elixirValue';
+
+  frontend = {
+    options = {
+      package = mkOption {
+        type = types.package;
+        description = mdDoc "Akkoma frontend package.";
+        example = literalExpression "pkgs.akkoma-frontends.pleroma-fe";
+      };
+
+      name = mkOption {
+        type = types.nonEmptyStr;
+        description = mdDoc "Akkoma frontend name.";
+        example = "pleroma-fe";
+      };
+
+      ref = mkOption {
+        type = types.nonEmptyStr;
+        description = mdDoc "Akkoma frontend reference.";
+        example = "stable";
+      };
+    };
+  };
+
+  sha256 = builtins.hashString "sha256";
+
+  replaceSec = let
+    replaceSec' = { }@args: v:
+      if isAttrs v
+        then if v ? _secret
+          then if isAbsolutePath v._secret
+            then sha256 v._secret
+            else abort "Invalid secret path (_secret = ${v._secret})"
+          else mapAttrs (_: val: replaceSec' args val) v
+        else if isList v
+          then map (replaceSec' args) v
+          else v;
+    in replaceSec' { };
+
+  # Erlang/Elixir uses a somewhat special format for IP addresses
+  erlAddr = addr: fileContents
+    (pkgs.runCommand addr {
+      nativeBuildInputs = with pkgs; [ elixir ];
+      code = ''
+        case :inet.parse_address('${addr}') do
+          {:ok, addr} -> IO.inspect addr
+          {:error, _} -> System.halt(65)
+        end
+      '';
+      passAsFile = [ "code" ];
+    } ''elixir "$codePath" >"$out"'');
+
+  format = pkgs.formats.elixirConf { };
+  configFile = format.generate "config.exs"
+    (replaceSec
+      (attrsets.updateManyAttrsByPath [{
+        path = [ ":pleroma" "Pleroma.Web.Endpoint" "http" "ip" ];
+        update = addr:
+          if isAbsolutePath addr
+            then format.lib.mkTuple
+              [ (format.lib.mkAtom ":local") addr ]
+            else format.lib.mkRaw (erlAddr addr);
+      }] cfg.config));
+
+  writeShell = { name, text, runtimeInputs ? [ ] }:
+    pkgs.writeShellApplication { inherit name text runtimeInputs; } + "/bin/${name}";
+
+  genScript = writeShell {
+    name = "akkoma-gen-cookie";
+    runtimeInputs = with pkgs; [ coreutils util-linux ];
+    text = ''
+      install -m 0400 \
+        -o ${escapeShellArg cfg.user } \
+        -g ${escapeShellArg cfg.group} \
+        <(hexdump -n 16 -e '"%02x"' /dev/urandom) \
+        "$RUNTIME_DIRECTORY/cookie"
+    '';
+  };
+
+  copyScript = writeShell {
+    name = "akkoma-copy-cookie";
+    runtimeInputs = with pkgs; [ coreutils ];
+    text = ''
+      install -m 0400 \
+        -o ${escapeShellArg cfg.user} \
+        -g ${escapeShellArg cfg.group} \
+        ${escapeShellArg cfg.dist.cookie._secret} \
+        "$RUNTIME_DIRECTORY/cookie"
+    '';
+  };
+
+  secretPaths = catAttrs "_secret" (collect isSecret cfg.config);
+
+  vapidKeygen = pkgs.writeText "vapidKeygen.exs" ''
+    [public_path, private_path] = System.argv()
+    {public_key, private_key} = :crypto.generate_key :ecdh, :prime256v1
+    File.write! public_path, Base.url_encode64(public_key, padding: false)
+    File.write! private_path, Base.url_encode64(private_key, padding: false)
+  '';
+
+  initSecretsScript = writeShell {
+    name = "akkoma-init-secrets";
+    runtimeInputs = with pkgs; [ coreutils elixir ];
+    text = let
+      key-base = web.secret_key_base;
+      jwt-signer = ex.":joken".":default_signer";
+      signing-salt = web.signing_salt;
+      liveview-salt = web.live_view.signing_salt;
+      vapid-private = ex.":web_push_encryption".":vapid_details".private_key;
+      vapid-public = ex.":web_push_encryption".":vapid_details".public_key;
+    in ''
+      secret() {
+        # Generate default secret if non‐existent
+        test -e "$2" || install -D -m 0600 <(tr -dc 'A-Za-z-._~' </dev/urandom | head -c "$1") "$2"
+        if [ "$(stat --dereference --format='%s' "$2")" -lt "$1" ]; then
+          echo "Secret '$2' is smaller than minimum size of $1 bytes." >&2
+          exit 65
+        fi
+      }
+
+      secret 64 ${escapeShellArg key-base._secret}
+      secret 64 ${escapeShellArg jwt-signer._secret}
+      secret 8 ${escapeShellArg signing-salt._secret}
+      secret 8 ${escapeShellArg liveview-salt._secret}
+
+      ${optionalString (isSecret vapid-public) ''
+        { test -e ${escapeShellArg vapid-private._secret} && \
+          test -e ${escapeShellArg vapid-public._secret}; } || \
+            elixir ${escapeShellArgs [ vapidKeygen vapid-public._secret vapid-private._secret ]}
+      ''}
+    '';
+  };
+
+  configScript = writeShell {
+    name = "akkoma-config";
+    runtimeInputs = with pkgs; [ coreutils replace-secret ];
+    text = ''
+      cd "$RUNTIME_DIRECTORY"
+      tmp="$(mktemp config.exs.XXXXXXXXXX)"
+      trap 'rm -f "$tmp"' EXIT TERM
+
+      cat ${escapeShellArg configFile} >"$tmp"
+      ${concatMapStrings (file: ''
+        replace-secret ${escapeShellArgs [ (sha256 file) file ]} "$tmp"
+      '') secretPaths}
+
+      chown ${escapeShellArg cfg.user}:${escapeShellArg cfg.group} "$tmp"
+      chmod 0400 "$tmp"
+      mv -f "$tmp" config.exs
+    '';
+  };
+
+  pgpass = let
+    esc = escape [ ":" ''\'' ];
+  in if (cfg.initDb.password != null)
+    then pkgs.writeText "pgpass.conf" ''
+      *:*:*${esc cfg.initDb.username}:${esc (sha256 cfg.initDb.password._secret)}
+    ''
+    else null;
+
+  escapeSqlId = x: ''"${replaceStrings [ ''"'' ] [ ''""'' ] x}"'';
+  escapeSqlStr = x: "'${replaceStrings [ "'" ] [ "''" ] x}'";
+
+  setupSql = pkgs.writeText "setup.psql" ''
+    \set ON_ERROR_STOP on
+
+    ALTER ROLE ${escapeSqlId db.username}
+      LOGIN PASSWORD ${if db ? password
+        then "${escapeSqlStr (sha256 db.password._secret)}"
+        else "NULL"};
+
+    ALTER DATABASE ${escapeSqlId db.database}
+      OWNER TO ${escapeSqlId db.username};
+
+    \connect ${escapeSqlId db.database}
+    CREATE EXTENSION IF NOT EXISTS citext;
+    CREATE EXTENSION IF NOT EXISTS pg_trgm;
+    CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+  '';
+
+  dbHost = if db ? socket_dir then db.socket_dir
+    else if db ? socket then db.socket
+      else if db ? hostname then db.hostname
+        else null;
+
+  initDbScript = writeShell {
+    name = "akkoma-initdb";
+    runtimeInputs = with pkgs; [ coreutils replace-secret config.services.postgresql.package ];
+    text = ''
+      pgpass="$(mktemp -t pgpass-XXXXXXXXXX.conf)"
+      setupSql="$(mktemp -t setup-XXXXXXXXXX.psql)"
+      trap 'rm -f "$pgpass $setupSql"' EXIT TERM
+
+      ${optionalString (dbHost != null) ''
+        export PGHOST=${escapeShellArg dbHost}
+      ''}
+      export PGUSER=${escapeShellArg cfg.initDb.username}
+      ${optionalString (pgpass != null) ''
+        cat ${escapeShellArg pgpass} >"$pgpass"
+        replace-secret ${escapeShellArgs [
+          (sha256 cfg.initDb.password._secret) cfg.initDb.password._secret ]} "$pgpass"
+        export PGPASSFILE="$pgpass"
+      ''}
+
+      cat ${escapeShellArg setupSql} >"$setupSql"
+      ${optionalString (db ? password) ''
+        replace-secret ${escapeShellArgs [
+         (sha256 db.password._secret) db.password._secret ]} "$setupSql"
+      ''}
+
+      # Create role if non‐existent
+      psql -tAc "SELECT 1 FROM pg_roles
+        WHERE rolname = "${escapeShellArg (escapeSqlStr db.username)} | grep -F -q 1 || \
+        psql -tAc "CREATE ROLE "${escapeShellArg (escapeSqlId db.username)}
+
+      # Create database if non‐existent
+      psql -tAc "SELECT 1 FROM pg_database
+        WHERE datname = "${escapeShellArg (escapeSqlStr db.database)} | grep -F -q 1 || \
+        psql -tAc "CREATE DATABASE "${escapeShellArg (escapeSqlId db.database)}"
+          OWNER "${escapeShellArg (escapeSqlId db.username)}"
+          TEMPLATE template0
+          ENCODING 'utf8'
+          LOCALE 'C'"
+
+      psql -f "$setupSql"
+    '';
+  };
+
+  envWrapper = let
+    script = writeShell {
+      name = "akkoma-env";
+      text = ''
+        cd "${cfg.package}"
+
+        RUNTIME_DIRECTORY="''${RUNTIME_DIRECTORY:-/run/akkoma}"
+        AKKOMA_CONFIG_PATH="$RUNTIME_DIRECTORY/config.exs" \
+        ERL_EPMD_ADDRESS="${cfg.dist.address}" \
+        ERL_EPMD_PORT="${toString cfg.dist.epmdPort}" \
+        ERL_FLAGS="${concatStringsSep " " [
+          "-kernel inet_dist_use_interface '${erlAddr cfg.dist.address}'"
+          "-kernel inet_dist_listen_min ${toString cfg.dist.portMin}"
+          "-kernel inet_dist_listen_max ${toString cfg.dist.portMax}"
+        ]}" \
+        RELEASE_COOKIE="$(<"$RUNTIME_DIRECTORY/cookie")" \
+        RELEASE_NAME="akkoma" \
+          exec "${cfg.package}/bin/$(basename "$0")" "$@"
+      '';
+    };
+  in pkgs.runCommandLocal "akkoma-env" { } ''
+    mkdir -p "$out/bin"
+
+    ln -r -s ${escapeShellArg script} "$out/bin/pleroma"
+    ln -r -s ${escapeShellArg script} "$out/bin/pleroma_ctl"
+  '';
+
+  userWrapper = pkgs.writeShellApplication {
+    name = "pleroma_ctl";
+    text = ''
+      if [ "''${1-}" == "update" ]; then
+        echo "OTP releases are not supported on NixOS." >&2
+        exit 64
+      fi
+
+      exec sudo -u ${escapeShellArg cfg.user} \
+        "${envWrapper}/bin/pleroma_ctl" "$@"
+    '';
+  };
+
+  socketScript = if isAbsolutePath web.http.ip
+    then writeShell {
+      name = "akkoma-socket";
+      runtimeInputs = with pkgs; [ coreutils inotify-tools ];
+      text = ''
+        coproc {
+          inotifywait -q -m -e create ${escapeShellArg (dirOf web.http.ip)}
+        }
+
+        trap 'kill "$COPROC_PID"' EXIT TERM
+
+        until test -S ${escapeShellArg web.http.ip}
+          do read -r -u "''${COPROC[0]}"
+        done
+
+        chmod 0666 ${escapeShellArg web.http.ip}
+      '';
+    }
+    else null;
+
+  staticDir = ex.":pleroma".":instance".static_dir;
+  uploadDir = ex.":pleroma".":instance".upload_dir;
+
+  staticFiles = pkgs.runCommandLocal "akkoma-static" { } ''
+    ${concatStringsSep "\n" (mapAttrsToList (key: val: ''
+      mkdir -p $out/frontends/${escapeShellArg val.name}/
+      ln -s ${escapeShellArg val.package} $out/frontends/${escapeShellArg val.name}/${escapeShellArg val.ref}
+    '') cfg.frontends)}
+
+    ${optionalString (cfg.extraStatic != null)
+      (concatStringsSep "\n" (mapAttrsToList (key: val: ''
+        mkdir -p "$out/$(dirname ${escapeShellArg key})"
+        ln -s ${escapeShellArg val} $out/${escapeShellArg key}
+      '') cfg.extraStatic))}
+  '';
+in {
+  options = {
+    services.akkoma = {
+      enable = mkEnableOption (mdDoc "Akkoma");
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.akkoma;
+        defaultText = literalExpression "pkgs.akkoma";
+        description = mdDoc "Akkoma package to use.";
+      };
+
+      user = mkOption {
+        type = types.nonEmptyStr;
+        default = "akkoma";
+        description = mdDoc "User account under which Akkoma runs.";
+      };
+
+      group = mkOption {
+        type = types.nonEmptyStr;
+        default = "akkoma";
+        description = mdDoc "Group account under which Akkoma runs.";
+      };
+
+      initDb = {
+        enable = mkOption {
+          type = types.bool;
+          default = true;
+          description = mdDoc ''
+            Whether to automatically initialise the database on startup. This will create a
+            database role and database if they do not already exist, and (re)set the role password
+            and the ownership of the database.
+
+            This setting can be used safely even if the database already exists and contains data.
+
+            The database settings are configured through
+            [{option}`config.services.akkoma.config.":pleroma"."Pleroma.Repo"`](#opt-services.akkoma.config.__pleroma_._Pleroma.Repo_).
+
+            If disabled, the database has to be set up manually:
+
+            ```SQL
+            CREATE ROLE akkoma LOGIN;
+
+            CREATE DATABASE akkoma
+              OWNER akkoma
+              TEMPLATE template0
+              ENCODING 'utf8'
+              LOCALE 'C';
+
+            \connect akkoma
+            CREATE EXTENSION IF NOT EXISTS citext;
+            CREATE EXTENSION IF NOT EXISTS pg_trgm;
+            CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+            ```
+          '';
+        };
+
+        username = mkOption {
+          type = types.nonEmptyStr;
+          default = config.services.postgresql.superUser;
+          defaultText = literalExpression "config.services.postgresql.superUser";
+          description = mdDoc ''
+            Name of the database user to initialise the database with.
+
+            This user is required to have the `CREATEROLE` and `CREATEDB` capabilities.
+          '';
+        };
+
+        password = mkOption {
+          type = types.nullOr secret;
+          default = null;
+          description = mdDoc ''
+            Password of the database user to initialise the database with.
+
+            If set to `null`, no password will be used.
+
+            The attribute `_secret` should point to a file containing the secret.
+          '';
+        };
+      };
+
+      initSecrets = mkOption {
+        type = types.bool;
+        default = true;
+        description = mdDoc ''
+          Whether to initialise non‐existent secrets with random values.
+
+          If enabled, appropriate secrets for the following options will be created automatically
+          if the files referenced in the `_secrets` attribute do not exist during startup.
+
+          - {option}`config.":pleroma"."Pleroma.Web.Endpoint".secret_key_base`
+          - {option}`config.":pleroma"."Pleroma.Web.Endpoint".signing_salt`
+          - {option}`config.":pleroma"."Pleroma.Web.Endpoint".live_view.signing_salt`
+          - {option}`config.":web_push_encryption".":vapid_details".private_key`
+          - {option}`config.":web_push_encryption".":vapid_details".public_key`
+          - {option}`config.":joken".":default_signer"`
+        '';
+      };
+
+      installWrapper = mkOption {
+        type = types.bool;
+        default = true;
+        description = mdDoc ''
+          Whether to install a wrapper around `pleroma_ctl` to simplify administration of the
+          Akkoma instance.
+        '';
+      };
+
+      extraPackages = mkOption {
+        type = with types; listOf package;
+        default = with pkgs; [ exiftool ffmpeg_5-headless graphicsmagick-imagemagick-compat ];
+        defaultText = literalExpression "with pkgs; [ exiftool graphicsmagick-imagemagick-compat ffmpeg_5-headless ]";
+        example = literalExpression "with pkgs; [ exiftool imagemagick ffmpeg_5-full ]";
+        description = mdDoc ''
+          List of extra packages to include in the executable search path of the service unit.
+          These are needed by various configurable components such as:
+
+          - ExifTool for the `Pleroma.Upload.Filter.Exiftool` upload filter,
+          - ImageMagick for still image previews in the media proxy as well as for the
+            `Pleroma.Upload.Filters.Mogrify` upload filter, and
+          - ffmpeg for video previews in the media proxy.
+        '';
+      };
+
+      frontends = mkOption {
+        description = mdDoc "Akkoma frontends.";
+        type = with types; attrsOf (submodule frontend);
+        default = {
+          primary = {
+            package = pkgs.akkoma-frontends.pleroma-fe;
+            name = "pleroma-fe";
+            ref = "stable";
+          };
+          admin = {
+            package = pkgs.akkoma-frontends.admin-fe;
+            name = "admin-fe";
+            ref = "stable";
+          };
+        };
+        defaultText = literalExpression ''
+          {
+            primary = {
+              package = pkgs.akkoma-frontends.pleroma-fe;
+              name = "pleroma-fe";
+              ref = "stable";
+            };
+            admin = {
+              package = pkgs.akkoma-frontends.admin-fe;
+              name = "admin-fe";
+              ref = "stable";
+            };
+          }
+        '';
+      };
+
+      extraStatic = mkOption {
+        type = with types; nullOr (attrsOf package);
+        description = mdDoc ''
+          Attribute set of extra packages to add to the static files directory.
+
+          Do not add frontends here. These should be configured through
+          [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends).
+        '';
+        default = null;
+        example = literalExpression ''
+          {
+            "emoji/blobs.gg" = pkgs.akkoma-emoji.blobs_gg;
+            "static/terms-of-service.html" = pkgs.writeText "terms-of-service.html" '''
+              …
+            ''';
+            "favicon.png" = let
+              rev = "697a8211b0f427a921e7935a35d14bb3e32d0a2c";
+            in pkgs.stdenvNoCC.mkDerivation {
+              name = "favicon.png";
+
+              src = pkgs.fetchurl {
+                url = "https://raw.githubusercontent.com/TilCreator/NixOwO/''${rev}/NixOwO_plain.svg";
+                hash = "sha256-tWhHMfJ3Od58N9H5yOKPMfM56hYWSOnr/TGCBi8bo9E=";
+              };
+
+              nativeBuildInputs = with pkgs; [ librsvg ];
+
+              dontUnpack = true;
+              installPhase = '''
+                rsvg-convert -o $out -w 96 -h 96 $src
+              ''';
+            };
+          }
+        '';
+      };
+
+      dist = {
+        address = mkOption {
+          type = ipAddress;
+          default = "127.0.0.1";
+          description = mdDoc ''
+            Listen address for Erlang distribution protocol and Port Mapper Daemon (epmd).
+          '';
+        };
+
+        epmdPort = mkOption {
+          type = types.port;
+          default = 4369;
+          description = mdDoc "TCP port to bind Erlang Port Mapper Daemon to.";
+        };
+
+        portMin = mkOption {
+          type = types.port;
+          default = 49152;
+          description = mdDoc "Lower bound for Erlang distribution protocol TCP port.";
+        };
+
+        portMax = mkOption {
+          type = types.port;
+          default = 65535;
+          description = mdDoc "Upper bound for Erlang distribution protocol TCP port.";
+        };
+
+        cookie = mkOption {
+          type = types.nullOr secret;
+          default = null;
+          example = { _secret = "/var/lib/secrets/akkoma/releaseCookie"; };
+          description = mdDoc ''
+            Erlang release cookie.
+
+            If set to `null`, a temporary random cookie will be generated.
+          '';
+        };
+      };
+
+      config = mkOption {
+        description = mdDoc ''
+          Configuration for Akkoma. The attributes are serialised to Elixir DSL.
+
+          Refer to <https://docs.akkoma.dev/stable/configuration/cheatsheet/> for
+          configuration options.
+
+          Settings containing secret data should be set to an attribute set containing the
+          attribute `_secret` - a string pointing to a file containing the value the option
+          should be set to.
+        '';
+        type = types.submodule {
+          freeformType = format.type;
+          options = {
+            ":pleroma" = {
+              ":instance" = {
+                name = mkOption {
+                  type = types.nonEmptyStr;
+                  description = mdDoc "Instance name.";
+                };
+
+                email = mkOption {
+                  type = types.nonEmptyStr;
+                  description = mdDoc "Instance administrator email.";
+                };
+
+                description = mkOption {
+                  type = types.nonEmptyStr;
+                  description = mdDoc "Instance description.";
+                };
+
+                static_dir = mkOption {
+                  type = types.path;
+                  default = toString staticFiles;
+                  defaultText = literalMD ''
+                    Derivation gathering the following paths into a directory:
+
+                    - [{option}`services.akkoma.frontends`](#opt-services.akkoma.frontends)
+                    - [{option}`services.akkoma.extraStatic`](#opt-services.akkoma.extraStatic)
+                  '';
+                  description = mdDoc ''
+                    Directory of static files.
+
+                    This directory can be built using a derivation, or it can be managed as mutable
+                    state by setting the option to an absolute path.
+                  '';
+                };
+
+                upload_dir = mkOption {
+                  type = absolutePath;
+                  default = "/var/lib/akkoma/uploads";
+                  description = mdDoc ''
+                    Directory where Akkoma will put uploaded files.
+                  '';
+                };
+              };
+
+              "Pleroma.Repo" = mkOption {
+                type = elixirValue;
+                default = {
+                  adapter = format.lib.mkRaw "Ecto.Adapters.Postgres";
+                  socket_dir = "/run/postgresql";
+                  username = cfg.user;
+                  database = "akkoma";
+                };
+                defaultText = literalExpression ''
+                  {
+                    adapter = (pkgs.formats.elixirConf { }).lib.mkRaw "Ecto.Adapters.Postgres";
+                    socket_dir = "/run/postgresql";
+                    username = config.services.akkoma.user;
+                    database = "akkoma";
+                  }
+                '';
+                description = mdDoc ''
+                  Database configuration.
+
+                  Refer to
+                  <https://hexdocs.pm/ecto_sql/Ecto.Adapters.Postgres.html#module-connection-options>
+                  for options.
+                '';
+              };
+
+              "Pleroma.Web.Endpoint" = {
+                url = {
+                  host = mkOption {
+                    type = types.nonEmptyStr;
+                    default = config.networking.fqdn;
+                    defaultText = literalExpression "config.networking.fqdn";
+                    description = mdDoc "Domain name of the instance.";
+                  };
+
+                  scheme = mkOption {
+                    type = types.nonEmptyStr;
+                    default = "https";
+                    description = mdDoc "URL scheme.";
+                  };
+
+                  port = mkOption {
+                    type = types.port;
+                    default = 443;
+                    description = mdDoc "External port number.";
+                  };
+                };
+
+                http = {
+                  ip = mkOption {
+                    type = types.either absolutePath ipAddress;
+                    default = "/run/akkoma/socket";
+                    example = "::1";
+                    description = mdDoc ''
+                      Listener IP address or Unix socket path.
+
+                      The value is automatically converted to Elixir’s internal address
+                      representation during serialisation.
+                    '';
+                  };
+
+                  port = mkOption {
+                    type = types.port;
+                    default = if isAbsolutePath web.http.ip then 0 else 4000;
+                    defaultText = literalExpression ''
+                      if isAbsolutePath config.services.akkoma.config.:pleroma"."Pleroma.Web.Endpoint".http.ip
+                        then 0
+                        else 4000;
+                    '';
+                    description = mdDoc ''
+                      Listener port number.
+
+                      Must be 0 if using a Unix socket.
+                    '';
+                  };
+                };
+
+                secret_key_base = mkOption {
+                  type = secret;
+                  default = { _secret = "/var/lib/secrets/akkoma/key-base"; };
+                  description = mdDoc ''
+                    Secret key used as a base to generate further secrets for encrypting and
+                    signing data.
+
+                    The attribute `_secret` should point to a file containing the secret.
+
+                    This key can generated can be generated as follows:
+
+                    ```ShellSession
+                    $ tr -dc 'A-Za-z-._~' </dev/urandom | head -c 64
+                    ```
+                  '';
+                };
+
+                live_view = {
+                  signing_salt = mkOption {
+                    type = secret;
+                    default = { _secret = "/var/lib/secrets/akkoma/liveview-salt"; };
+                    description = mdDoc ''
+                      LiveView signing salt.
+
+                      The attribute `_secret` should point to a file containing the secret.
+
+                      This salt can be generated as follows:
+
+                      ```ShellSession
+                      $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8
+                      ```
+                    '';
+                  };
+                };
+
+                signing_salt = mkOption {
+                  type = secret;
+                  default = { _secret = "/var/lib/secrets/akkoma/signing-salt"; };
+                  description = mdDoc ''
+                    Signing salt.
+
+                    The attribute `_secret` should point to a file containing the secret.
+
+                    This salt can be generated as follows:
+
+                    ```ShellSession
+                    $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 8
+                    ```
+                  '';
+                };
+              };
+
+              ":frontends" = mkOption {
+                type = elixirValue;
+                default = mapAttrs
+                  (key: val: format.lib.mkMap { name = val.name; ref = val.ref; })
+                  cfg.frontends;
+                defaultText = literalExpression ''
+                  lib.mapAttrs (key: val:
+                    (pkgs.formats.elixirConf { }).lib.mkMap { name = val.name; ref = val.ref; })
+                    config.services.akkoma.frontends;
+                '';
+                description = mdDoc ''
+                  Frontend configuration.
+
+                  Users should rely on the default value and prefer to configure frontends through
+                  [{option}`config.services.akkoma.frontends`](#opt-services.akkoma.frontends).
+                '';
+              };
+            };
+
+            ":web_push_encryption" = mkOption {
+              default = { };
+              description = mdDoc ''
+                Web Push Notifications configuration.
+
+                The necessary key pair can be generated as follows:
+
+                ```ShellSession
+                $ nix-shell -p nodejs --run 'npx web-push generate-vapid-keys'
+                ```
+              '';
+              type = types.submodule {
+                freeformType = elixirValue;
+                options = {
+                  ":vapid_details" = {
+                    subject = mkOption {
+                      type = types.nonEmptyStr;
+                      default = "mailto:${ex.":pleroma".":instance".email}";
+                      defaultText = literalExpression ''
+                        "mailto:''${config.services.akkoma.config.":pleroma".":instance".email}"
+                      '';
+                      description = mdDoc "mailto URI for administrative contact.";
+                    };
+
+                    public_key = mkOption {
+                      type = with types; either nonEmptyStr secret;
+                      default = { _secret = "/var/lib/secrets/akkoma/vapid-public"; };
+                      description = mdDoc "base64-encoded public ECDH key.";
+                    };
+
+                    private_key = mkOption {
+                      type = secret;
+                      default = { _secret = "/var/lib/secrets/akkoma/vapid-private"; };
+                      description = mdDoc ''
+                        base64-encoded private ECDH key.
+
+                        The attribute `_secret` should point to a file containing the secret.
+                      '';
+                    };
+                  };
+                };
+              };
+            };
+
+            ":joken" = {
+              ":default_signer" = mkOption {
+                type = secret;
+                default = { _secret = "/var/lib/secrets/akkoma/jwt-signer"; };
+                description = mdDoc ''
+                  JWT signing secret.
+
+                  The attribute `_secret` should point to a file containing the secret.
+
+                  This secret can be generated as follows:
+
+                  ```ShellSession
+                  $ tr -dc 'A-Za-z0-9-._~' </dev/urandom | head -c 64
+                  ```
+                '';
+              };
+            };
+
+            ":logger" = {
+              ":backends" = mkOption {
+                type = types.listOf elixirValue;
+                visible = false;
+                default = with format.lib; [
+                  (mkTuple [ (mkRaw "ExSyslogger") (mkAtom ":ex_syslogger") ])
+                ];
+              };
+
+              ":ex_syslogger" = {
+                ident = mkOption {
+                  type = types.str;
+                  visible = false;
+                  default = "akkoma";
+                };
+
+                level = mkOption {
+                  type = types.nonEmptyStr;
+                  apply = format.lib.mkAtom;
+                  default = ":info";
+                  example = ":warning";
+                  description = mdDoc ''
+                    Log level.
+
+                    Refer to
+                    <https://hexdocs.pm/logger/Logger.html#module-levels>
+                    for options.
+                  '';
+                };
+              };
+            };
+
+            ":tzdata" = {
+              ":data_dir" = mkOption {
+                type = elixirValue;
+                internal = true;
+                default = format.lib.mkRaw ''
+                  Path.join(System.fetch_env!("CACHE_DIRECTORY"), "tzdata")
+                '';
+              };
+            };
+          };
+        };
+      };
+
+      nginx = mkOption {
+        type = with types; nullOr (submodule
+          (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }));
+        default = null;
+        description = mdDoc ''
+          Extra configuration for the nginx virtual host of Akkoma.
+
+          If set to `null`, no virtual host will be added to the nginx configuration.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    warnings = optionals (!config.security.sudo.enable) [''
+      The pleroma_ctl wrapper enabled by the installWrapper option relies on
+      sudo, which appears to have been disabled through security.sudo.enable.
+    ''];
+
+    users = {
+      users."${cfg.user}" = {
+        description = "Akkoma user";
+        group = cfg.group;
+        isSystemUser = true;
+      };
+      groups."${cfg.group}" = { };
+    };
+
+    # Confinement of the main service unit requires separation of the
+    # configuration generation into a separate unit to permit access to secrets
+    # residing outside of the chroot.
+    systemd.services.akkoma-config = {
+      description = "Akkoma social network configuration";
+      reloadTriggers = [ configFile ] ++ secretPaths;
+
+      unitConfig.PropagatesReloadTo = [ "akkoma.service" ];
+      serviceConfig = {
+        Type = "oneshot";
+        RemainAfterExit = true;
+        UMask = "0077";
+
+        RuntimeDirectory = "akkoma";
+
+        ExecStart = mkMerge [
+          (mkIf (cfg.dist.cookie == null) [ genScript ])
+          (mkIf (cfg.dist.cookie != null) [ copyScript ])
+          (mkIf cfg.initSecrets [ initSecretsScript ])
+          [ configScript ]
+        ];
+
+        ExecReload = mkMerge [
+          (mkIf cfg.initSecrets [ initSecretsScript ])
+          [ configScript ]
+        ];
+      };
+    };
+
+    systemd.services.akkoma-initdb = mkIf cfg.initDb.enable {
+      description = "Akkoma social network database setup";
+      requires = [ "akkoma-config.service" ];
+      requiredBy = [ "akkoma.service" ];
+      after = [ "akkoma-config.service" "postgresql.service" ];
+      before = [ "akkoma.service" ];
+
+      serviceConfig = {
+        Type = "oneshot";
+        User = mkIf (db ? socket_dir || db ? socket)
+          cfg.initDb.username;
+        RemainAfterExit = true;
+        UMask = "0077";
+        ExecStart = initDbScript;
+        PrivateTmp = true;
+      };
+    };
+
+    systemd.services.akkoma = let
+      runtimeInputs = with pkgs; [ coreutils gawk gnused ] ++ cfg.extraPackages;
+    in {
+      description = "Akkoma social network";
+      documentation = [ "https://docs.akkoma.dev/stable/" ];
+
+      # This service depends on network-online.target and is sequenced after
+      # it because it requires access to the Internet to function properly.
+      bindsTo = [ "akkoma-config.service" ];
+      wants = [ "network-online.service" ];
+      wantedBy = [ "multi-user.target" ];
+      after = [
+        "akkoma-config.target"
+        "network.target"
+        "network-online.target"
+        "postgresql.service"
+      ];
+
+      confinement.packages = mkIf isConfined runtimeInputs;
+      path = runtimeInputs;
+
+      serviceConfig = {
+        Type = "exec";
+        User = cfg.user;
+        Group = cfg.group;
+        UMask = "0077";
+
+        # The run‐time directory is preserved as it is managed by the akkoma-config.service unit.
+        RuntimeDirectory = "akkoma";
+        RuntimeDirectoryPreserve = true;
+
+        CacheDirectory = "akkoma";
+
+        BindPaths = [ "${uploadDir}:${uploadDir}:norbind" ];
+        BindReadOnlyPaths = mkMerge [
+          (mkIf (!isStorePath staticDir) [ "${staticDir}:${staticDir}:norbind" ])
+          (mkIf isConfined (mkMerge [
+            [ "/etc/hosts" "/etc/resolv.conf" ]
+            (mkIf (isStorePath staticDir) (map (dir: "${dir}:${dir}:norbind")
+              (splitString "\n" (readFile ((pkgs.closureInfo { rootPaths = staticDir; }) + "/store-paths")))))
+            (mkIf (db ? socket_dir) [ "${db.socket_dir}:${db.socket_dir}:norbind" ])
+            (mkIf (db ? socket) [ "${db.socket}:${db.socket}:norbind" ])
+          ]))
+        ];
+
+        ExecStartPre = "${envWrapper}/bin/pleroma_ctl migrate";
+        ExecStart = "${envWrapper}/bin/pleroma start";
+        ExecStartPost = socketScript;
+        ExecStop = "${envWrapper}/bin/pleroma stop";
+        ExecStopPost = mkIf (isAbsolutePath web.http.ip)
+          "${pkgs.coreutils}/bin/rm -f '${web.http.ip}'";
+
+        ProtectProc = "noaccess";
+        ProcSubset = "pid";
+        ProtectSystem = mkIf (!isConfined) "strict";
+        ProtectHome = true;
+        PrivateTmp = true;
+        PrivateDevices = true;
+        PrivateIPC = true;
+        ProtectHostname = true;
+        ProtectClock = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectKernelLogs = true;
+        ProtectControlGroups = true;
+
+        RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
+        RestrictNamespaces = true;
+        LockPersonality = true;
+        RestrictRealtime = true;
+        RestrictSUIDSGID = true;
+        RemoveIPC = true;
+
+        CapabilityBoundingSet = mkIf
+          (any (port: port > 0 && port < 1024)
+            [ web.http.port cfg.dist.epmdPort cfg.dist.portMin ])
+          [ "CAP_NET_BIND_SERVICE" ];
+
+        NoNewPrivileges = true;
+        SystemCallFilter = [ "@system-service" "~@privileged" "@chown" ];
+        SystemCallArchitectures = "native";
+
+        DeviceAllow = null;
+        DevicePolicy = "closed";
+
+        # SMTP adapter uses dynamic port 0 binding, which is incompatible with bind address filtering
+        SocketBindAllow = mkIf (!hasSmtp) (mkMerge [
+          [ "tcp:${toString cfg.dist.epmdPort}" "tcp:${toString cfg.dist.portMin}-${toString cfg.dist.portMax}" ]
+          (mkIf (web.http.port != 0) [ "tcp:${toString web.http.port}" ])
+        ]);
+        SocketBindDeny = mkIf (!hasSmtp) "any";
+      };
+    };
+
+    systemd.tmpfiles.rules = [
+      "d ${uploadDir}  0700 ${cfg.user} ${cfg.group} - -"
+      "Z ${uploadDir} ~0700 ${cfg.user} ${cfg.group} - -"
+    ];
+
+    environment.systemPackages = mkIf (cfg.installWrapper) [ userWrapper ];
+
+    services.nginx.virtualHosts = mkIf (cfg.nginx != null) {
+      ${web.url.host} = mkMerge [ cfg.nginx {
+        locations."/" = {
+          proxyPass =
+            if isAbsolutePath web.http.ip
+              then "http://unix:${web.http.ip}"
+              else if hasInfix ":" web.http.ip
+                then "http://[${web.http.ip}]:${toString web.http.port}"
+                else "http://${web.http.ip}:${toString web.http.port}";
+
+          proxyWebsockets = true;
+          recommendedProxySettings = true;
+        };
+      }];
+    };
+  };
+
+  meta.maintainers = with maintainers; [ mvs ];
+  meta.doc = ./akkoma.xml;
+}
diff --git a/nixos/modules/services/web-apps/akkoma.xml b/nixos/modules/services/web-apps/akkoma.xml
new file mode 100644
index 00000000000..76e6b806f30
--- /dev/null
+++ b/nixos/modules/services/web-apps/akkoma.xml
@@ -0,0 +1,396 @@
+<chapter xmlns="http://docbook.org/ns/docbook" xmlns:xlink="http://www.w3.org/1999/xlink" xml:id="module-services-akkoma">
+  <title>Akkoma</title>
+  <para>
+    <link xlink:href="https://akkoma.dev/">Akkoma</link> is a
+    lightweight ActivityPub microblogging server forked from Pleroma.
+  </para>
+  <section xml:id="modules-services-akkoma-service-configuration">
+    <title>Service configuration</title>
+    <para>
+      The Elixir configuration file required by Akkoma is generated
+      automatically from
+      <link xlink:href="options.html#opt-services.akkoma.config"><option>services.akkoma.config</option></link>.
+      Secrets must be included from external files outside of the Nix
+      store by setting the configuration option to an attribute set
+      containing the attribute <option>_secret</option> – a string
+      pointing to the file containing the actual value of the option.
+    </para>
+    <para>
+      For the mandatory configuration settings these secrets will be
+      generated automatically if the referenced file does not exist
+      during startup, unless disabled through
+      <link xlink:href="options.html#opt-services.akkoma.initSecrets"><option>services.akkoma.initSecrets</option></link>.
+    </para>
+    <para>
+      The following configuration binds Akkoma to the Unix socket
+      <literal>/run/akkoma/socket</literal>, expecting to be run behind
+      a HTTP proxy on <literal>fediverse.example.com</literal>.
+    </para>
+    <programlisting language="nix">
+services.akkoma.enable = true;
+services.akkoma.config = {
+  &quot;:pleroma&quot; = {
+    &quot;:instance&quot; = {
+      name = &quot;My Akkoma instance&quot;;
+      description = &quot;More detailed description&quot;;
+      email = &quot;admin@example.com&quot;;
+      registration_open = false;
+    };
+
+    &quot;Pleroma.Web.Endpoint&quot; = {
+      url.host = &quot;fediverse.example.com&quot;;
+    };
+  };
+};
+</programlisting>
+    <para>
+      Please refer to the
+      <link xlink:href="https://docs.akkoma.dev/stable/configuration/cheatsheet/">configuration
+      cheat sheet</link> for additional configuration options.
+    </para>
+  </section>
+  <section xml:id="modules-services-akkoma-user-management">
+    <title>User management</title>
+    <para>
+      After the Akkoma service is running, the administration utility
+      can be used to
+      <link xlink:href="https://docs.akkoma.dev/stable/administration/CLI_tasks/user/">manage
+      users</link>. In particular an administrative user can be created
+      with
+    </para>
+    <programlisting>
+$ pleroma_ctl user new &lt;nickname&gt; &lt;email&gt; --admin --moderator --password &lt;password&gt;
+</programlisting>
+  </section>
+  <section xml:id="modules-services-akkoma-proxy-configuration">
+    <title>Proxy configuration</title>
+    <para>
+      Although it is possible to expose Akkoma directly, it is common
+      practice to operate it behind an HTTP reverse proxy such as nginx.
+    </para>
+    <programlisting language="nix">
+services.akkoma.nginx = {
+  enableACME = true;
+  forceSSL = true;
+};
+
+services.nginx = {
+  enable = true;
+
+  clientMaxBodySize = &quot;16m&quot;;
+  recommendedTlsSettings = true;
+  recommendedOptimisation = true;
+  recommendedGzipSettings = true;
+};
+</programlisting>
+    <para>
+      Please refer to <xref linkend="module-security-acme" /> for
+      details on how to provision an SSL/TLS certificate.
+    </para>
+    <section xml:id="modules-services-akkoma-media-proxy">
+      <title>Media proxy</title>
+      <para>
+        Without the media proxy function, Akkoma does not store any
+        remote media like pictures or video locally, and clients have to
+        fetch them directly from the source server.
+      </para>
+      <programlisting language="nix">
+# Enable nginx slice module distributed with Tengine
+services.nginx.package = pkgs.tengine;
+
+# Enable media proxy
+services.akkoma.config.&quot;:pleroma&quot;.&quot;:media_proxy&quot; = {
+  enabled = true;
+  proxy_opts.redirect_on_failure = true;
+};
+
+# Adjust the persistent cache size as needed:
+#  Assuming an average object size of 128 KiB, around 1 MiB
+#  of memory is required for the key zone per GiB of cache.
+# Ensure that the cache directory exists and is writable by nginx.
+services.nginx.commonHttpConfig = ''
+  proxy_cache_path /var/cache/nginx/cache/akkoma-media-cache
+    levels= keys_zone=akkoma_media_cache:16m max_size=16g
+    inactive=1y use_temp_path=off;
+'';
+
+services.akkoma.nginx = {
+  locations.&quot;/proxy&quot; = {
+    proxyPass = &quot;http://unix:/run/akkoma/socket&quot;;
+
+    extraConfig = ''
+      proxy_cache akkoma_media_cache;
+
+      # Cache objects in slices of 1 MiB
+      slice 1m;
+      proxy_cache_key $host$uri$is_args$args$slice_range;
+      proxy_set_header Range $slice_range;
+
+      # Decouple proxy and upstream responses
+      proxy_buffering on;
+      proxy_cache_lock on;
+      proxy_ignore_client_abort on;
+
+      # Default cache times for various responses
+      proxy_cache_valid 200 1y;
+      proxy_cache_valid 206 301 304 1h;
+
+      # Allow serving of stale items
+      proxy_cache_use_stale error timeout invalid_header updating;
+    '';
+  };
+};
+</programlisting>
+      <section xml:id="modules-services-akkoma-prefetch-remote-media">
+        <title>Prefetch remote media</title>
+        <para>
+          The following example enables the
+          <literal>MediaProxyWarmingPolicy</literal> MRF policy which
+          automatically fetches all media associated with a post through
+          the media proxy, as soon as the post is received by the
+          instance.
+        </para>
+        <programlisting language="nix">
+services.akkoma.config.&quot;:pleroma&quot;.&quot;:mrf&quot;.policies =
+  map (pkgs.formats.elixirConf { }).lib.mkRaw [
+    &quot;Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy&quot;
+];
+</programlisting>
+      </section>
+      <section xml:id="modules-services-akkoma-media-previews">
+        <title>Media previews</title>
+        <para>
+          Akkoma can generate previews for media.
+        </para>
+        <programlisting language="nix">
+services.akkoma.config.&quot;:pleroma&quot;.&quot;:media_preview_proxy&quot; = {
+  enabled = true;
+  thumbnail_max_width = 1920;
+  thumbnail_max_height = 1080;
+};
+</programlisting>
+      </section>
+    </section>
+  </section>
+  <section xml:id="modules-services-akkoma-frontend-management">
+    <title>Frontend management</title>
+    <para>
+      Akkoma will be deployed with the <literal>pleroma-fe</literal> and
+      <literal>admin-fe</literal> frontends by default. These can be
+      modified by setting
+      <link xlink:href="options.html#opt-services.akkoma.frontends"><option>services.akkoma.frontends</option></link>.
+    </para>
+    <para>
+      The following example overrides the primary frontend’s default
+      configuration using a custom derivation.
+    </para>
+    <programlisting language="nix">
+services.akkoma.frontends.primary.package = pkgs.runCommand &quot;pleroma-fe&quot; {
+  config = builtins.toJSON {
+    expertLevel = 1;
+    collapseMessageWithSubject = false;
+    stopGifs = false;
+    replyVisibility = &quot;following&quot;;
+    webPushHideIfCW = true;
+    hideScopeNotice = true;
+    renderMisskeyMarkdown = false;
+    hideSiteFavicon = true;
+    postContentType = &quot;text/markdown&quot;;
+    showNavShortcuts = false;
+  };
+  nativeBuildInputs = with pkgs; [ jq xorg.lndir ];
+  passAsFile = [ &quot;config&quot; ];
+} ''
+  mkdir $out
+  lndir ${pkgs.akkoma-frontends.pleroma-fe} $out
+
+  rm $out/static/config.json
+  jq -s add ${pkgs.akkoma-frontends.pleroma-fe}/static/config.json ${config} \
+    &gt;$out/static/config.json
+'';
+</programlisting>
+  </section>
+  <section xml:id="modules-services-akkoma-federation-policies">
+    <title>Federation policies</title>
+    <para>
+      Akkoma comes with a number of modules to police federation with
+      other ActivityPub instances. The most valuable for typical users
+      is the
+      <link xlink:href="https://docs.akkoma.dev/stable/configuration/cheatsheet/#mrf_simple"><literal>:mrf_simple</literal></link>
+      module which allows limiting federation based on instance
+      hostnames.
+    </para>
+    <para>
+      This configuration snippet provides an example on how these can be
+      used. Choosing an adequate federation policy is not trivial and
+      entails finding a balance between connectivity to the rest of the
+      fediverse and providing a pleasant experience to the users of an
+      instance.
+    </para>
+    <programlisting language="nix">
+services.akkoma.config.&quot;:pleroma&quot; = with (pkgs.formats.elixirConf { }).lib; {
+  &quot;:mrf&quot;.policies = map mkRaw [
+    &quot;Pleroma.Web.ActivityPub.MRF.SimplePolicy&quot;
+  ];
+
+  &quot;:mrf_simple&quot; = {
+    # Tag all media as sensitive
+    media_nsfw = mkMap {
+      &quot;nsfw.weird.kinky&quot; = &quot;Untagged NSFW content&quot;;
+    };
+
+    # Reject all activities except deletes
+    reject = mkMap {
+      &quot;kiwifarms.cc&quot; = &quot;Persistent harassment of users, no moderation&quot;;
+    };
+
+    # Force posts to be visible by followers only
+    followers_only = mkMap {
+      &quot;beta.birdsite.live&quot; = &quot;Avoid polluting timelines with Twitter posts&quot;;
+    };
+  };
+};
+</programlisting>
+  </section>
+  <section xml:id="modules-services-akkoma-upload-filters">
+    <title>Upload filters</title>
+    <para>
+      This example strips GPS and location metadata from uploads,
+      deduplicates them and anonymises the the file name.
+    </para>
+    <programlisting language="nix">
+services.akkoma.config.&quot;:pleroma&quot;.&quot;Pleroma.Upload&quot;.filters =
+  map (pkgs.formats.elixirConf { }).lib.mkRaw [
+    &quot;Pleroma.Upload.Filter.Exiftool&quot;
+    &quot;Pleroma.Upload.Filter.Dedupe&quot;
+    &quot;Pleroma.Upload.Filter.AnonymizeFilename&quot;
+  ];
+</programlisting>
+  </section>
+  <section xml:id="modules-services-akkoma-migration-pleroma">
+    <title>Migration from Pleroma</title>
+    <para>
+      Pleroma instances can be migrated to Akkoma either by copying the
+      database and upload data or by pointing Akkoma to the existing
+      data. The necessary database migrations are run automatically
+      during startup of the service.
+    </para>
+    <para>
+      The configuration has to be copy‐edited manually.
+    </para>
+    <para>
+      Depending on the size of the database, the initial migration may
+      take a long time and exceed the startup timeout of the system
+      manager. To work around this issue one may adjust the startup
+      timeout
+      <option>systemd.services.akkoma.serviceConfig.TimeoutStartSec</option>
+      or simply run the migrations manually:
+    </para>
+    <programlisting>
+pleroma_ctl migrate
+</programlisting>
+    <section xml:id="modules-services-akkoma-migration-pleroma-copy">
+      <title>Copying data</title>
+      <para>
+        Copying the Pleroma data instead of re‐using it in place may
+        permit easier reversion to Pleroma, but allows the two data sets
+        to diverge.
+      </para>
+      <para>
+        First disable Pleroma and then copy its database and upload
+        data:
+      </para>
+      <programlisting>
+# Create a copy of the database
+nix-shell -p postgresql --run 'createdb -T pleroma akkoma'
+
+# Copy upload data
+mkdir /var/lib/akkoma
+cp -R --reflink=auto /var/lib/pleroma/uploads /var/lib/akkoma/
+</programlisting>
+      <para>
+        After the data has been copied, enable the Akkoma service and
+        verify that the migration has been successful. If no longer
+        required, the original data may then be deleted:
+      </para>
+      <programlisting>
+# Delete original database
+nix-shell -p postgresql --run 'dropdb pleroma'
+
+# Delete original Pleroma state
+rm -r /var/lib/pleroma
+</programlisting>
+    </section>
+    <section xml:id="modules-services-akkoma-migration-pleroma-reuse">
+      <title>Re‐using data</title>
+      <para>
+        To re‐use the Pleroma data in place, disable Pleroma and enable
+        Akkoma, pointing it to the Pleroma database and upload
+        directory.
+      </para>
+      <programlisting language="nix">
+# Adjust these settings according to the database name and upload directory path used by Pleroma
+services.akkoma.config.&quot;:pleroma&quot;.&quot;Pleroma.Repo&quot;.database = &quot;pleroma&quot;;
+services.akkoma.config.&quot;:pleroma&quot;.&quot;:instance&quot;.upload_dir = &quot;/var/lib/pleroma/uploads&quot;;
+</programlisting>
+      <para>
+        Please keep in mind that after the Akkoma service has been
+        started, any migrations applied by Akkoma have to be rolled back
+        before the database can be used again with Pleroma. This can be
+        achieved through <literal>pleroma_ctl ecto.rollback</literal>.
+        Refer to the
+        <link xlink:href="https://hexdocs.pm/ecto_sql/Mix.Tasks.Ecto.Rollback.html">Ecto
+        SQL documentation</link> for details.
+      </para>
+    </section>
+  </section>
+  <section xml:id="modules-services-akkoma-advanced-deployment">
+    <title>Advanced deployment options</title>
+    <section xml:id="modules-services-akkoma-confinement">
+      <title>Confinement</title>
+      <para>
+        The Akkoma systemd service may be confined to a chroot with
+      </para>
+      <programlisting language="nix">
+services.systemd.akkoma.confinement.enable = true;
+</programlisting>
+      <para>
+        Confinement of services is not generally supported in NixOS and
+        therefore disabled by default. Depending on the Akkoma
+        configuration, the default confinement settings may be
+        insufficient and lead to subtle errors at run time, requiring
+        adjustment:
+      </para>
+      <para>
+        Use
+        <link xlink:href="options.html#opt-systemd.services._name_.confinement.packages"><option>services.systemd.akkoma.confinement.packages</option></link>
+        to make packages available in the chroot.
+      </para>
+      <para>
+        <option>services.systemd.akkoma.serviceConfig.BindPaths</option>
+        and
+        <option>services.systemd.akkoma.serviceConfig.BindReadOnlyPaths</option>
+        permit access to outside paths through bind mounts. Refer to
+        <link xlink:href="https://www.freedesktop.org/software/systemd/man/systemd.exec.html#BindPaths="><link xlink:href="https://www.freedesktop.org/software/systemd/man/systemd.exec.html"><citerefentry><refentrytitle>systemd.exec</refentrytitle><manvolnum>5</manvolnum></citerefentry></link></link>
+        for details.
+      </para>
+    </section>
+    <section xml:id="modules-services-akkoma-distributed-deployment">
+      <title>Distributed deployment</title>
+      <para>
+        Being an Elixir application, Akkoma can be deployed in a
+        distributed fashion.
+      </para>
+      <para>
+        This requires setting
+        <link xlink:href="options.html#opt-services.akkoma.dist.address"><option>services.akkoma.dist.address</option></link>
+        and
+        <link xlink:href="options.html#opt-services.akkoma.dist.cookie"><option>services.akkoma.dist.cookie</option></link>.
+        The specifics depend strongly on the deployment environment. For
+        more information please check the relevant
+        <link xlink:href="https://www.erlang.org/doc/reference_manual/distributed.html">Erlang
+        documentation</link>.
+      </para>
+    </section>
+  </section>
+</chapter>