Skip to content

Nix Module

OpenPost can also run through a NixOS module. The hosted app at https://app.openpost.social uses this setup.

This page is for NixOS operators. The module requires an existing reverse proxy and explicit secret management.

What this example shows

  • Running OpenPost as an OCI container
  • Persisting SQLite and media storage under /var/lib/openpost
  • Supplying secrets through sops
  • Wiring public callback and media URLs to the deployed domain
  • Exposing the service through your existing reverse proxy layer

Current module

Source: rodrgds/nix-config/modules/services/openpost/default.nix

The public example uses the moving :latest tag. The linked deployment source may pin a verified image digest.

nix
# OpenPost - Multi-platform social media posting
# https://github.com/getopenpost/openpost
{
  config,
  lib,
  pkgs,
  ...
}:
let
  cfg = config.vps.openpost;
  isCloud = cfg.edition == "cloud";

  # Host port (external) - must be unique per service
  openpostHostPort = 8090;
  # Container port (internal) - OpenPost listens on 8080 inside container
  openpostContainerPort = 8080;
  openpostPostgresUser = "openpost";
  openpostPostgresDatabase = "openpost";

  openpostFileSecrets = [
    {
      name = "jwt-secret";
      env = "OPENPOST_JWT_SECRET_FILE";
      target = "/run/secrets/openpost_jwt_secret";
      value = config.sops.placeholder.openpost_jwt_secret;
    }
    {
      name = "encryption-key";
      env = "OPENPOST_ENCRYPTION_KEY_FILE";
      target = "/run/secrets/openpost_encryption_key";
      value = config.sops.placeholder.openpost_encryption_key;
    }
    {
      name = "posthog-project-token";
      env = "OPENPOST_POSTHOG_PROJECT_TOKEN_FILE";
      target = "/run/secrets/openpost_posthog_project_token";
      value = config.sops.placeholder.openpost_posthog_project_token;
    }
    {
      name = "provider-apps";
      env = "OPENPOST_PROVIDER_APPS_FILE";
      target = "/run/secrets/openpost_provider_apps";
      value = config.sops.placeholder.openpost_provider_apps;
    }
    {
      name = "google-auth-client-id";
      env = "OPENPOST_AUTH_GOOGLE_CLIENT_ID_FILE";
      target = "/run/secrets/openpost_google_auth_client_id";
      value = config.sops.placeholder.openpost_google_auth_client_id;
    }
    {
      name = "google-auth-client-secret";
      env = "OPENPOST_AUTH_GOOGLE_CLIENT_SECRET_FILE";
      target = "/run/secrets/openpost_google_auth_client_secret";
      value = config.sops.placeholder.openpost_google_auth_client_secret;
    }
    {
      name = "pexels-api-key";
      env = "OPENPOST_PEXELS_API_KEY_FILE";
      target = "/run/secrets/openpost_pexels_api_key";
      value = config.sops.placeholder.openpost_pexels_api_key;
    }
    {
      name = "pixabay-api-key";
      env = "OPENPOST_PIXABAY_API_KEY_FILE";
      target = "/run/secrets/openpost_pixabay_api_key";
      value = config.sops.placeholder.openpost_pixabay_api_key;
    }
    {
      name = "unsplash-access-key";
      env = "OPENPOST_UNSPLASH_ACCESS_KEY_FILE";
      target = "/run/secrets/openpost_unsplash_access_key";
      value = config.sops.placeholder.openpost_unsplash_access_key;
    }
    {
      name = "feedback-webhook";
      env = "OPENPOST_FEEDBACK_DESTINATION_URL_FILE";
      target = "/run/secrets/openpost_feedback_webhook";
      value = config.sops.placeholder.openpost_feedback_webhook;
    }
    {
      name = "smtp-password";
      env = "OPENPOST_SMTP_PASSWORD_FILE";
      target = "/run/secrets/openpost_smtp_password";
      value = config.sops.placeholder.openpost_smtp_password;
    }
    {
      name = "x-client-id";
      env = "X_CLIENT_ID_FILE";
      target = "/run/secrets/openpost_twitter_client_id";
      value = config.sops.placeholder.openpost_twitter_client_id;
    }
    {
      name = "x-client-secret";
      env = "X_CLIENT_SECRET_FILE";
      target = "/run/secrets/openpost_twitter_client_secret";
      value = config.sops.placeholder.openpost_twitter_client_secret;
    }
    {
      name = "linkedin-client-id";
      env = "LINKEDIN_CLIENT_ID_FILE";
      target = "/run/secrets/openpost_linkedin_client_id";
      value = config.sops.placeholder.openpost_linkedin_client_id;
    }
    {
      name = "linkedin-client-secret";
      env = "LINKEDIN_CLIENT_SECRET_FILE";
      target = "/run/secrets/openpost_linkedin_client_secret";
      value = config.sops.placeholder.openpost_linkedin_client_secret;
    }
    {
      name = "threads-client-id";
      env = "THREADS_CLIENT_ID_FILE";
      target = "/run/secrets/openpost_threads_client_id";
      value = config.sops.placeholder.openpost_threads_client_id;
    }
    {
      name = "threads-client-secret";
      env = "THREADS_CLIENT_SECRET_FILE";
      target = "/run/secrets/openpost_threads_client_secret";
      value = config.sops.placeholder.openpost_threads_client_secret;
    }
    # {
    #   name = "mastodon-servers";
    #   env = "MASTODON_SERVERS_FILE";
    #   target = "/run/secrets/openpost_mastodon_servers";
    #   value = config.sops.placeholder.openpost_mastodon_servers;
    # }
    {
      name = "openrouter-api-key";
      env = "OPENROUTER_API_KEY_FILE";
      target = "/run/secrets/openpost_openrouter_api_key";
      value = config.sops.placeholder.openpost_openrouter_api_key;
    }
  ];

  openpostFileSecretEnvironment = lib.listToAttrs (
    map (secret: lib.nameValuePair secret.env secret.target) openpostFileSecrets
  );

  openpostFileSecretTemplates = lib.listToAttrs (
    map (
      secret:
      lib.nameValuePair "openpost-${secret.name}" {
        content = secret.value;
        mode = "0444";
      }
    ) openpostFileSecrets
  );

  openpostFileSecretMounts = map (
    secret:
    let
      templateName = "openpost-${secret.name}";
    in
    "--mount=type=bind,source=${config.sops.templates.${templateName}.path},target=${secret.target},ro"
  ) openpostFileSecrets;

in
{
  options.vps.openpost = {
    enable = lib.mkEnableOption "Enable OpenPost";

    edition = lib.mkOption {
      type = lib.types.enum [
        "selfhost"
        "cloud"
      ];
      default = "selfhost";
      description = ''
        OpenPost edition. `selfhost` keeps SQLite and local media defaults.
        `cloud` wires the container for Postgres and S3-compatible storage.
      '';
    };

    domain = lib.mkOption {
      type = lib.types.str;
      default = "app.openpost.social";
      description = "Domain for OpenPost";
    };

    timezone = lib.mkOption {
      type = lib.types.str;
      default = "Europe/Lisbon";
      description = "Timezone for OpenPost";
    };

    image = lib.mkOption {
      type = lib.types.str;
      default = "ghcr.io/getopenpost/openpost:latest";
      description = ''
        OpenPost container image. Pin this to a release tag or digest for
        reproducible production deploys.
      '';
    };

    pullPolicy = lib.mkOption {
      type = lib.types.enum [
        "always"
        "missing"
        "never"
      ];
      default = "always";
      description = ''
        Podman image pull policy for OpenPost. The default keeps the hosted
        service from reusing a stale local `latest` image after a Nix switch.
      '';
    };

    extraEnvironment = lib.mkOption {
      type = lib.types.attrsOf lib.types.str;
      default = { };
      description = ''
        Extra OpenPost environment variables. Use this for non-secret cloud
        settings, temporary overrides, or *_FILE pointers to mounted secrets.
      '';
    };

    extraEnvironmentFiles = lib.mkOption {
      type = lib.types.listOf lib.types.path;
      default = [ ];
      description = ''
        Extra env files passed to the OpenPost container. In cloud mode this
        should provide OPENPOST_DATABASE_URL, S3 credentials, Paddle billing
        secrets, or *_FILE pointers unless they are set via extraEnvironment.
      '';
    };

    extraOptions = lib.mkOption {
      type = lib.types.listOf lib.types.str;
      default = [ ];
      description = ''
        Extra Podman options for OpenPost. Use this to bind-mount additional
        secret files referenced by *_FILE environment variables.
      '';
    };
  };

  config = lib.mkIf cfg.enable {
    # Create persistent directories
    # Note: Container runs as user 'openpost' (UID 1000)
    systemd.tmpfiles.rules = [
      "d /var/lib/openpost 0755 root root -"
    ]
    ++ lib.optionals isCloud [
      "d /var/lib/openpost/postgres 0700 70 70 -"
      "d /var/backup/openpost 0700 root root -"
    ]
    ++ lib.optionals (!isCloud) [
      "d /var/lib/openpost/data 0755 1000 1000 -"
      "d /var/lib/openpost/data/db 0755 1000 1000 -"
      "d /var/lib/openpost/data/media 0755 1000 1000 -"
    ];

    # OpenPost application
    virtualisation.oci-containers.containers.openpost = {
      inherit (cfg) image;

      environment = {
        OPENPOST_PORT = toString openpostContainerPort;
        OPENPOST_EDITION = cfg.edition;
        OPENPOST_APP_URL = "https://${cfg.domain}";
        OPENPOST_PUBLIC_URL = "https://${cfg.domain}";
        OPENPOST_EXTRA_CORS_ORIGINS = "https://${cfg.domain}";
        OPENPOST_DISABLE_REGISTRATIONS = "false";
        OPENPOST_STOCK_MEDIA_ENABLED = "true";
        LINKEDIN_DISABLE_THREAD_REPLIES = "true";
        X_REDIRECT_URI = "https://${cfg.domain}/api/v1/accounts/x/callback";
        LINKEDIN_REDIRECT_URI = "https://${cfg.domain}/api/v1/accounts/linkedin/callback";
        THREADS_REDIRECT_URI = "https://${cfg.domain}/api/v1/accounts/threads/callback";
        MASTODON_REDIRECT_URI = "https://${cfg.domain}/api/v1/accounts/mastodon/callback";
        TZ = cfg.timezone;
      }
      // openpostFileSecretEnvironment
      // (
        if isCloud then
          {
            OPENPOST_DATABASE_DRIVER = "postgres";
            OPENPOST_STORAGE_DRIVER = "s3";
          }
        else
          {
            OPENPOST_DATABASE_DRIVER = "sqlite";
            OPENPOST_DATABASE_PATH = "/data/db/openpost.db";
            OPENPOST_STORAGE_DRIVER = "local";
            OPENPOST_MEDIA_PATH = "/data/media";
            OPENPOST_MEDIA_URL = "https://${cfg.domain}/media";
          }
      )
      // cfg.extraEnvironment;

      environmentFiles =
        cfg.extraEnvironmentFiles
        ++ lib.optionals isCloud [
          config.sops.templates.openpost-cloud-env.path
        ];

      volumes = lib.optionals (!isCloud) [
        "/var/lib/openpost/data:/data"
      ];

      dependsOn = lib.optionals isCloud [ "openpost-postgres" ];

      ports = [
        "127.0.0.1:${toString openpostHostPort}:${toString openpostContainerPort}"
      ];

      extraOptions = [
        "--network=podman"
        "--pull=${cfg.pullPolicy}"
        "--health-cmd=sh -ec 'attempt=0; until wget --spider http://localhost:${toString openpostContainerPort}/api/v1/ready; do attempt=$((attempt + 1)); [ \"$attempt\" -ge 60 ] && exit 1; sleep 1; done'"
        "--health-interval=30s"
        "--health-timeout=75s"
        "--health-retries=3"
        "--health-start-period=60s"
      ]
      ++ openpostFileSecretMounts
      ++ cfg.extraOptions;
    };

    virtualisation.oci-containers.containers.openpost-postgres = lib.mkIf isCloud {
      image = "docker.io/postgres:17-alpine";

      environmentFiles = [
        config.sops.templates.openpost-postgres-env.path
      ];

      volumes = [
        "/var/lib/openpost/postgres:/var/lib/postgresql/data"
      ];

      extraOptions = [
        "--network=podman"
        "--health-cmd=pg_isready -U ${openpostPostgresUser} -d ${openpostPostgresDatabase}"
        "--health-interval=10s"
        "--health-timeout=5s"
        "--health-retries=12"
      ];
    };

    sops.templates =
      openpostFileSecretTemplates
      // lib.optionalAttrs isCloud {
        "openpost-postgres-env" = {
          content = ''
            POSTGRES_USER=${openpostPostgresUser}
            POSTGRES_DB=${openpostPostgresDatabase}
            POSTGRES_PASSWORD=${config.sops.placeholder.openpost_postgres_password}
          '';
          mode = "0444";
        };
        "openpost-cloud-env" = {
          content = ''
            OPENPOST_DATABASE_URL=postgres://${openpostPostgresUser}:${config.sops.placeholder.openpost_postgres_password}@openpost-postgres:5432/${openpostPostgresDatabase}?sslmode=disable
            OPENPOST_S3_ENDPOINT=${config.sops.placeholder.openpost_s3_endpoint}
            OPENPOST_S3_REGION=${config.sops.placeholder.openpost_s3_region}
            OPENPOST_S3_BUCKET=${config.sops.placeholder.openpost_s3_bucket}
            OPENPOST_S3_ACCESS_KEY_ID=${config.sops.placeholder.openpost_s3_access_key_id}
            OPENPOST_S3_SECRET_ACCESS_KEY=${config.sops.placeholder.openpost_s3_secret_access_key}
            OPENPOST_S3_PUBLIC_BASE_URL=${config.sops.placeholder.openpost_s3_public_base_url}
            OPENPOST_LEGAL_ACCEPTANCE_REQUIRED=true
            OPENPOST_TERMS_URL=https://openpost.social/terms
            OPENPOST_PRIVACY_URL=https://openpost.social/privacy
            OPENPOST_TERMS_VERSION=2026-08-05
            OPENPOST_PRIVACY_VERSION=2026-08-11
            OPENPOST_TELEMETRY_ENABLED=true
            OPENPOST_POSTHOG_API_HOST=https://eu.i.posthog.com
            OPENPOST_POSTHOG_BROWSER_HOST=https://eu.i.posthog.com
            OPENPOST_POSTHOG_UI_HOST=https://eu.posthog.com
            OPENPOST_TELEMETRY_ENVIRONMENT=production
            OPENPOST_SUPPORT_EMAIL=hello@openpost.social
            OPENPOST_EMAIL_VERIFICATION_REQUIRED=true
            OPENPOST_EMAIL_PROVIDER=smtp
            OPENPOST_EMAIL_FROM=hello@openpost.social
            OPENPOST_SMTP_HOST=smtp.purelymail.com
            OPENPOST_SMTP_PORT=465
            OPENPOST_SMTP_USERNAME=hello@openpost.social
            OPENPOST_SMTP_FROM=hello@openpost.social
            OPENPOST_SMTP_TLS_MODE=tls
            OPENPOST_SMTP_SERVER_NAME=smtp.purelymail.com
            OPENPOST_IMAGE_CAPTION_PROVIDER=azure/eu
            OPENPOST_IMAGE_CAPTION_REQUIRE_ZDR=true
            OPENPOST_PADDLE_API_KEY=${config.sops.placeholder.openpost_paddle_api_key}
            OPENPOST_PADDLE_ENVIRONMENT=production
            OPENPOST_PADDLE_CLIENT_TOKEN=${config.sops.placeholder.openpost_paddle_client_token}
            OPENPOST_PADDLE_WEBHOOK_SECRET=${config.sops.placeholder.openpost_paddle_webhook_secret}
            OPENPOST_PADDLE_CHECKOUT_RETURN_URL=https://${cfg.domain}/checkout?status=success
            OPENPOST_PADDLE_STARTER_MONTHLY_PRICE_ID=pri_01kz8y75epf02dvf9yt0hcbxsr
            OPENPOST_PADDLE_STARTER_ANNUAL_PRICE_ID=pri_01kz8y75zmdb45ferqj6dq1s68
            OPENPOST_PADDLE_FOUNDER_MONTHLY_PRICE_ID=pri_01kz8y774fgdve480x8pcd4tzq
            OPENPOST_PADDLE_FOUNDER_ANNUAL_PRICE_ID=pri_01kz8y77nfx8myhzjbbrnpfn5f
            OPENPOST_PADDLE_PRO_MONTHLY_PRICE_ID=pri_01kz8y78txwwdhbvte7gsjkpr3
            OPENPOST_PADDLE_PRO_ANNUAL_PRICE_ID=pri_01kz8y79je6s27tgpgw2s6kpnb
            OPENPOST_PADDLE_TEAM_MONTHLY_PRICE_ID=pri_01kz8y7argrs3zygh0j73wmf9n
            OPENPOST_PADDLE_TEAM_ANNUAL_PRICE_ID=pri_01kz8y7b9n73r8v989skf9hbj1
            OPENPOST_PADDLE_AGENCY_MONTHLY_PRICE_ID=pri_01kz8y7ccz8ve0gp2erm4yvssw
            OPENPOST_PADDLE_AGENCY_ANNUAL_PRICE_ID=pri_01kz8y7cy4bjsmtdtjwpwns4wf
          '';
          mode = "0444";
        };
        "openpost-backup-env" = {
          content = ''
            RCLONE_CONFIG_OPENPOST_TYPE=s3
            RCLONE_CONFIG_OPENPOST_PROVIDER=Other
            RCLONE_CONFIG_OPENPOST_ENDPOINT=${config.sops.placeholder.openpost_s3_endpoint}
            RCLONE_CONFIG_OPENPOST_REGION=${config.sops.placeholder.openpost_s3_region}
            RCLONE_CONFIG_OPENPOST_ACCESS_KEY_ID=${config.sops.placeholder.openpost_s3_access_key_id}
            RCLONE_CONFIG_OPENPOST_SECRET_ACCESS_KEY=${config.sops.placeholder.openpost_s3_secret_access_key}
            OPENPOST_BACKUP_S3_BUCKET=${config.sops.placeholder.openpost_s3_bucket}
          '';
          mode = "0400";
        };
      };

    systemd.services.openpost-postgres-backup = lib.mkIf isCloud {
      description = "Backup OpenPost Postgres database";
      serviceConfig = {
        Type = "oneshot";
        UMask = "0077";
        ExecStart = pkgs.writeShellScript "openpost-postgres-backup" ''
          set -euo pipefail
          timestamp=$(${pkgs.coreutils}/bin/date +%Y%m%d_%H%M%S)
          backup_dir=/var/backup/openpost
          ${pkgs.coreutils}/bin/mkdir -p "$backup_dir"
          backup_path="$backup_dir/openpost_$timestamp.sql.gz"
          backup_tmp=$(${pkgs.coreutils}/bin/mktemp "$backup_dir/.openpost_$timestamp.sql.gz.XXXXXX")
          cleanup() {
            ${pkgs.coreutils}/bin/rm -f -- "$backup_tmp"
          }
          trap cleanup EXIT

          ${pkgs.podman}/bin/podman exec openpost-postgres pg_dump \
            -U ${openpostPostgresUser} \
            -d ${openpostPostgresDatabase} | ${pkgs.gzip}/bin/gzip > "$backup_tmp"
          ${pkgs.gzip}/bin/gzip -t "$backup_tmp"
          ${pkgs.coreutils}/bin/chmod 0600 "$backup_tmp"
          ${pkgs.coreutils}/bin/mv "$backup_tmp" "$backup_path"
          trap - EXIT

          ${pkgs.findutils}/bin/find "$backup_dir" -name 'openpost_*.sql.gz' -mtime +14 -delete
        '';
      };
    };

    systemd.timers.openpost-postgres-backup = lib.mkIf isCloud {
      description = "Daily OpenPost Postgres backup";
      wantedBy = [ "timers.target" ];
      timerConfig = {
        OnCalendar = "daily";
        Persistent = true;
      };
    };

    systemd.services.openpost-media-backup = lib.mkIf isCloud {
      description = "Backup OpenPost S3 media with retained changed and deleted objects";
      serviceConfig = {
        Type = "oneshot";
        UMask = "0077";
        EnvironmentFile = config.sops.templates."openpost-backup-env".path;
        ExecStart = pkgs.writeShellScript "openpost-media-backup" ''
          set -euo pipefail
          timestamp=$(${pkgs.coreutils}/bin/date -u +%Y%m%d_%H%M%S)
          backup_root=/var/backup/openpost
          media_current="$backup_root/media-current"
          media_versions="$backup_root/media-versions/$timestamp"
          ${pkgs.coreutils}/bin/mkdir -p "$media_current" "$media_versions"

          ${pkgs.rclone}/bin/rclone sync \
            "openpost:$OPENPOST_BACKUP_S3_BUCKET" \
            "$media_current" \
            --backup-dir "$media_versions" \
            --fast-list \
            --checkers 8 \
            --transfers 4

          ${pkgs.rclone}/bin/rclone check \
            "openpost:$OPENPOST_BACKUP_S3_BUCKET" \
            "$media_current" \
            --one-way \
            --size-only

          ${pkgs.findutils}/bin/find "$backup_root/media-versions" \
            -mindepth 1 -maxdepth 1 -type d -mtime +14 \
            -exec ${pkgs.coreutils}/bin/rm -rf -- {} +
        '';
      };
    };

    systemd.timers.openpost-media-backup = lib.mkIf isCloud {
      description = "Daily OpenPost media backup";
      wantedBy = [ "timers.target" ];
      timerConfig = {
        OnCalendar = "daily";
        Persistent = true;
      };
    };

    systemd.services.openpost-restore-drill = lib.mkIf isCloud {
      description = "Restore and validate the latest OpenPost backup";
      after = [ "podman-openpost-postgres.service" ];
      requires = [ "podman-openpost-postgres.service" ];
      serviceConfig = {
        Type = "oneshot";
        UMask = "0077";
        ExecStart = pkgs.writeShellScript "openpost-restore-drill" ''
          set -euo pipefail
          backup_root=/var/backup/openpost
          latest_backup=$(${pkgs.findutils}/bin/find "$backup_root" -maxdepth 1 -type f -name 'openpost_*.sql.gz' -printf '%T@ %p\n' | ${pkgs.coreutils}/bin/sort -nr | ${pkgs.gawk}/bin/awk 'NR == 1 { print $2 }')
          if [ -z "$latest_backup" ]; then
            echo "No OpenPost database backup found" >&2
            exit 1
          fi

          ${pkgs.gzip}/bin/gzip -t "$latest_backup"
          restore_database="openpost_restore_drill_$(${pkgs.coreutils}/bin/date -u +%Y%m%d_%H%M%S)"
          database_created=false
          cleanup() {
            if [ "$database_created" = true ]; then
              ${pkgs.podman}/bin/podman exec openpost-postgres dropdb \
                --if-exists -U ${openpostPostgresUser} "$restore_database" >/dev/null
            fi
          }
          trap cleanup EXIT

          ${pkgs.podman}/bin/podman exec openpost-postgres createdb \
            -U ${openpostPostgresUser} "$restore_database"
          database_created=true
          ${pkgs.gzip}/bin/gzip -dc "$latest_backup" | ${pkgs.podman}/bin/podman exec -i \
            openpost-postgres psql -v ON_ERROR_STOP=1 \
            -U ${openpostPostgresUser} -d "$restore_database" >/dev/null

          table_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \
            "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'" \
            -U ${openpostPostgresUser} -d "$restore_database")
          user_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \
            "SELECT count(*) FROM users" -U ${openpostPostgresUser} -d "$restore_database")
          workspace_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \
            "SELECT count(*) FROM workspaces" -U ${openpostPostgresUser} -d "$restore_database")
          post_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \
            "SELECT count(*) FROM posts" -U ${openpostPostgresUser} -d "$restore_database")
          database_media_count=$(${pkgs.podman}/bin/podman exec openpost-postgres psql -Atqc \
            "SELECT count(*) FROM media_attachments" -U ${openpostPostgresUser} -d "$restore_database")
          media_file_count=$(${pkgs.findutils}/bin/find "$backup_root/media-current" -type f | ${pkgs.coreutils}/bin/wc -l)

          if [ "$table_count" -lt 10 ]; then
            echo "Restore has too few public tables: $table_count" >&2
            exit 1
          fi
          if [ "$database_media_count" -gt 0 ] && [ "$media_file_count" -eq 0 ]; then
            echo "Restore contains media records but the media snapshot is empty" >&2
            exit 1
          fi

          checked_at=$(${pkgs.coreutils}/bin/date -u +%Y-%m-%dT%H:%M:%SZ)
          backup_name=$(${pkgs.coreutils}/bin/basename "$latest_backup")
          backup_size=$(${pkgs.coreutils}/bin/wc -c < "$latest_backup")
          evidence_tmp=$(${pkgs.coreutils}/bin/mktemp "$backup_root/restore-drill-latest.json.XXXXXX")
          {
            printf '{\n'
            printf '  "status": "passed",\n'
            printf '  "checked_at": "%s",\n' "$checked_at"
            printf '  "backup": "%s",\n' "$backup_name"
            printf '  "backup_bytes": %s,\n' "$backup_size"
            printf '  "public_tables": %s,\n' "$table_count"
            printf '  "users": %s,\n' "$user_count"
            printf '  "workspaces": %s,\n' "$workspace_count"
            printf '  "posts": %s,\n' "$post_count"
            printf '  "database_media": %s,\n' "$database_media_count"
            printf '  "media_files": %s\n' "$media_file_count"
            printf '}\n'
          } > "$evidence_tmp"
          ${pkgs.coreutils}/bin/chmod 0600 "$evidence_tmp"
          ${pkgs.coreutils}/bin/mv "$evidence_tmp" "$backup_root/restore-drill-latest.json"
        '';
      };
    };

    systemd.timers.openpost-restore-drill = lib.mkIf isCloud {
      description = "Weekly OpenPost restore drill";
      wantedBy = [ "timers.target" ];
      timerConfig = {
        OnCalendar = "Sun 04:00";
        Persistent = true;
      };
    };

    vps.caddy.internalPorts.openpost = openpostHostPort;
  };
}

Open source under AGPL-3.0-only.