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";
runtimeContract = builtins.fromJSON (builtins.readFile ./runtime-contract.json);
# Host port (external) - must be unique per service
openpostHostPort = 8090;
# Container port (internal) - OpenPost listens on 8080 inside container
openpostContainerPort = 8080;
# Map the image user into a non-login host range so the interactive UID 1000
# cannot read runtime secrets or application data from the host.
openpostContainerUid = runtimeContract.containerUid;
openpostContainerGid = runtimeContract.containerGid;
openpostHostIdBase = runtimeContract.hostIdBase;
openpostHostUid = openpostHostIdBase + openpostContainerUid;
openpostHostGid = openpostHostIdBase + openpostContainerGid;
openpostPostgresUser = "openpost";
openpostPostgresDatabase = "openpost";
openpostPostgresImage = "docker.io/library/postgres:17-alpine@sha256:0a8a1e76503c091f0feb387d51b10fcd746c2d61cf6cdd6e8356973a45e40a0f";
openpostImageRepository = lib.removeSuffix ":latest" cfg.image;
openpostApplicationUnits = [
"podman-openpost.service"
]
++ lib.optional isCloud "podman-openpost-worker.service";
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;
uid = openpostHostUid;
gid = openpostHostGid;
mode = "0400";
restartUnits = openpostApplicationUnits;
}
) openpostFileSecrets
);
openpostFileSecretMounts = map (
secret:
let
templateName = "openpost-${secret.name}";
in
"--mount=type=bind,source=${config.sops.templates.${templateName}.path},target=${secret.target},ro"
) openpostFileSecrets;
openpostApplicationEnvironment = {
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;
openpostApplicationEnvironmentFiles =
cfg.extraEnvironmentFiles
++ lib.optionals isCloud [
config.sops.templates.openpost-cloud-env.path
];
openpostApplicationOptions = [
"--network=podman"
"--uidmap=0:${toString openpostHostIdBase}:65536"
"--gidmap=0:${toString openpostHostIdBase}:65536"
"--pull=${cfg.pullPolicy}"
];
stopManagedContainer =
name:
pkgs.writeShellScript "stop-${name}" ''
set -euo pipefail
if ${pkgs.podman}/bin/podman container exists ${lib.escapeShellArg name}; then
# Podman 5.8 can leave its transient systemd timer loaded after this
# update. Quiesce the container-ID-specific timer and any active probe
# before replacement so neither can fail after the container is gone.
${pkgs.podman}/bin/podman update --health-interval=disable ${lib.escapeShellArg name} >/dev/null
read -r container_id < /run/${name}/ctr-id
[[ "$container_id" =~ ^[0-9a-f]{64}$ ]] || {
echo "invalid ${name} container ID" >&2
exit 1
}
for unit_type in timer service; do
while read -r unit _; do
[ -n "$unit" ] || continue
${pkgs.systemd}/bin/systemctl stop "$unit" || true
if [ "$unit_type" = service ]; then
${pkgs.systemd}/bin/systemctl reset-failed "$unit" || true
fi
done < <(
${pkgs.systemd}/bin/systemctl list-units \
--all --plain --no-legend "$container_id-*.$unit_type"
)
done
fi
${pkgs.podman}/bin/podman stop --ignore --cidfile=/run/${name}/ctr-id
'';
openpostOpsAlert = pkgs.writeShellScript "openpost-ops-alert" ''
set -euo pipefail
[ "$#" -eq 1 ] || { echo "expected a failed systemd unit" >&2; exit 1; }
unit="$1"
[[ "$unit" =~ ^[A-Za-z0-9@_.:-]+$ ]] || { echo "invalid systemd unit" >&2; exit 1; }
webhook_url="$(${pkgs.coreutils}/bin/tr -d '\r\n' < ${
config.sops.templates."openpost-feedback-webhook".path
})"
case "$webhook_url" in
https://discord.com/api/webhooks/*|https://discordapp.com/api/webhooks/*) ;;
*) echo "OpenPost operations webhook is not an approved Discord URL" >&2; exit 1 ;;
esac
result="$(${pkgs.systemd}/bin/systemctl show "$unit" --property=Result --value 2>/dev/null || printf unknown)"
message="OpenPost operations failure on $(${pkgs.inetutils}/bin/hostname): $unit result=$result at $(${pkgs.coreutils}/bin/date -u +%Y-%m-%dT%H:%M:%SZ)"
payload="$(${pkgs.jq}/bin/jq -cn --arg content "$message" '{content: $content}')"
printf 'url = "%s"\n' "$webhook_url" \
| ${pkgs.curl}/bin/curl \
--config - \
--fail \
--silent \
--show-error \
--connect-timeout 10 \
--max-time 30 \
--header 'Content-Type: application/json' \
--data-binary "$payload" \
--output /dev/null
'';
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. Keep this aligned with the local tag promoted
by the signed deployment hook because the VPS service uses pullPolicy =
"never" and must not fetch an unverified registry reference at restart.
'';
};
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.
'';
};
bootstrapDigest = lib.mkOption {
type = lib.types.nullOr (lib.types.strMatching "sha256:[0-9a-f]{64}");
default = null;
description = "Immutable OpenPost image digest used only to seed a clean host.";
};
bootstrapRevision = lib.mkOption {
type = lib.types.nullOr (lib.types.strMatching "[0-9a-f]{40}");
default = null;
description = "Source revision required on the clean-host bootstrap image.";
};
offsiteBackup.enable = lib.mkEnableOption "encrypted off-host OpenPost backups and log archives";
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 {
sops.secrets = lib.genAttrs (
[
"openpost_jwt_secret"
"openpost_encryption_key"
"openpost_posthog_project_token"
"openpost_provider_apps"
"openpost_google_auth_client_id"
"openpost_google_auth_client_secret"
"openpost_pexels_api_key"
"openpost_pixabay_api_key"
"openpost_unsplash_access_key"
"openpost_feedback_webhook"
"openpost_smtp_password"
"openpost_twitter_client_id"
"openpost_twitter_client_secret"
"openpost_linkedin_client_id"
"openpost_linkedin_client_secret"
"openpost_threads_client_id"
"openpost_threads_client_secret"
"openpost_openrouter_api_key"
]
++ lib.optionals isCloud [
"openpost_postgres_password"
"openpost_s3_endpoint"
"openpost_s3_region"
"openpost_s3_bucket"
"openpost_s3_access_key_id"
"openpost_s3_secret_access_key"
"openpost_s3_public_base_url"
"openpost_paddle_api_key"
"openpost_paddle_client_token"
"openpost_paddle_webhook_secret"
]
++ lib.optionals (isCloud && cfg.offsiteBackup.enable) [
"openpost_backup_s3_endpoint"
"openpost_backup_s3_region"
"openpost_backup_s3_bucket"
"openpost_backup_s3_access_key_id"
"openpost_backup_s3_secret_access_key"
"openpost_backup_restic_password"
]
) (_: { });
# 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 0750 ${toString openpostHostUid} ${toString openpostHostGid} -"
"d /var/lib/openpost/data/db 0750 ${toString openpostHostUid} ${toString openpostHostGid} -"
"d /var/lib/openpost/data/media 0750 ${toString openpostHostUid} ${toString openpostHostGid} -"
];
assertions = [
{
assertion = (cfg.bootstrapDigest == null) == (cfg.bootstrapRevision == null);
message = "OpenPost bootstrapDigest and bootstrapRevision must be configured together.";
}
{
assertion = cfg.bootstrapDigest == null || lib.hasSuffix ":latest" cfg.image;
message = "OpenPost clean-host bootstrap requires the managed image to use the :latest tag.";
}
];
# OpenPost HTTP application. Hosted workers run in a separate container so
# web traffic and durable job throughput can scale and fail independently.
virtualisation.oci-containers.containers.openpost = {
inherit (cfg) image;
user = "${toString openpostContainerUid}:${toString openpostContainerGid}";
cmd = [
"./openpost"
(if isCloud then "web" else "all")
];
environment = openpostApplicationEnvironment;
environmentFiles = openpostApplicationEnvironmentFiles;
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 =
openpostApplicationOptions
++ [
"--health-cmd=sh -ec 'attempt=0; until wget --spider http://localhost:${toString openpostContainerPort}/api/v1/health; 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"
"--memory=1536m"
"--memory-reservation=256m"
"--memory-swap=1536m"
"--cpus=1.5"
"--pids-limit=512"
]
++ openpostFileSecretMounts
++ cfg.extraOptions;
};
virtualisation.oci-containers.containers.openpost-worker = lib.mkIf isCloud {
inherit (cfg) image;
user = "${toString openpostContainerUid}:${toString openpostContainerGid}";
cmd = [
"./openpost"
"worker"
];
environment = openpostApplicationEnvironment;
environmentFiles = openpostApplicationEnvironmentFiles;
dependsOn = [ "openpost-postgres" ];
extraOptions =
openpostApplicationOptions
++ [
"--health-cmd=sh -ec 'kill -0 1'"
"--health-interval=30s"
"--health-timeout=3s"
"--health-retries=3"
"--health-start-period=5s"
"--memory=1024m"
"--memory-reservation=256m"
"--memory-swap=1024m"
"--cpus=1.0"
"--pids-limit=512"
]
++ openpostFileSecretMounts
++ cfg.extraOptions;
};
virtualisation.oci-containers.containers.openpost-postgres = lib.mkIf isCloud {
image = openpostPostgresImage;
environmentFiles = [
config.sops.templates.openpost-postgres-env.path
];
volumes = [
"/var/lib/openpost/postgres:/var/lib/postgresql/data"
];
extraOptions = [
"--network=podman"
"--health-cmd=sh -ec 'attempt=0; until pg_isready -U ${openpostPostgresUser} -d ${openpostPostgresDatabase}; do attempt=$((attempt + 1)); [ \"$attempt\" -ge 60 ] && exit 1; sleep 1; done'"
"--health-interval=10s"
"--health-timeout=75s"
"--health-retries=12"
"--health-start-period=60s"
"--memory=1536m"
"--memory-reservation=256m"
"--memory-swap=1536m"
"--cpus=1.5"
"--pids-limit=256"
];
};
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 = "0400";
restartUnits = [
"podman-openpost-postgres.service"
"openpost-postgres-credential-reconcile.service"
]
++ openpostApplicationUnits;
};
"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 = "0400";
restartUnits = openpostApplicationUnits;
};
"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";
};
}
// lib.optionalAttrs (isCloud && cfg.offsiteBackup.enable) {
"openpost-offsite-backup-env" = {
content = ''
AWS_ACCESS_KEY_ID=${config.sops.placeholder.openpost_backup_s3_access_key_id}
AWS_SECRET_ACCESS_KEY=${config.sops.placeholder.openpost_backup_s3_secret_access_key}
AWS_DEFAULT_REGION=${config.sops.placeholder.openpost_backup_s3_region}
RESTIC_REPOSITORY=s3:${config.sops.placeholder.openpost_backup_s3_endpoint}/${config.sops.placeholder.openpost_backup_s3_bucket}/openpost
RESTIC_PASSWORD=${config.sops.placeholder.openpost_backup_restic_password}
'';
mode = "0400";
};
};
systemd.services.openpost-image-bootstrap = lib.mkIf (cfg.bootstrapDigest != null) {
description = "Seed the exact OpenPost image on a clean host";
before = openpostApplicationUnits;
requiredBy = openpostApplicationUnits;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.writeShellScript "openpost-image-bootstrap" ''
set -euo pipefail
image=${lib.escapeShellArg cfg.image}
if ${pkgs.podman}/bin/podman image exists "$image"; then
exit 0
fi
candidate=${lib.escapeShellArg "${openpostImageRepository}@${cfg.bootstrapDigest}"}
${pkgs.podman}/bin/podman pull "$candidate"
revision="$(${pkgs.podman}/bin/podman image inspect "$candidate" --format '{{index .Labels "org.opencontainers.image.revision"}}')"
if [ "$revision" != ${lib.escapeShellArg cfg.bootstrapRevision} ]; then
echo "OpenPost bootstrap image revision $revision does not match the configured revision" >&2
exit 1
fi
${pkgs.podman}/bin/podman tag "$candidate" "$image"
'';
};
};
systemd.services."openpost-ops-alert@" = {
description = "Send an OpenPost operations failure alert for %i";
serviceConfig = {
Type = "oneshot";
ExecStart = "${openpostOpsAlert} %i";
UMask = "0077";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
};
};
systemd.services.openpost-postgres-credential-reconcile = lib.mkIf isCloud {
description = "Reconcile the authoritative OpenPost PostgreSQL credential";
after = [ "podman-openpost-postgres.service" ];
requires = [ "podman-openpost-postgres.service" ];
before = openpostApplicationUnits;
unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ];
serviceConfig = {
Type = "oneshot";
EnvironmentFile = config.sops.templates.openpost-postgres-env.path;
UMask = "0077";
ExecStart = pkgs.writeShellScript "openpost-postgres-credential-reconcile" ''
set -euo pipefail
for attempt in $(${pkgs.coreutils}/bin/seq 1 60); do
if ${pkgs.podman}/bin/podman exec openpost-postgres pg_isready \
-U ${openpostPostgresUser} -d ${openpostPostgresDatabase} >/dev/null; then
break
fi
if [ "$attempt" = 60 ]; then
echo "OpenPost PostgreSQL did not become ready for credential reconciliation" >&2
exit 1
fi
${pkgs.coreutils}/bin/sleep 1
done
encoded_password="$(printf '%s' "$POSTGRES_PASSWORD" | ${pkgs.coreutils}/bin/base64 | ${pkgs.coreutils}/bin/tr -d '\n')"
printf "SELECT format('ALTER ROLE ${openpostPostgresUser} PASSWORD %%L', convert_from(decode('%s', 'base64'), 'UTF8')) \\gexec\n" "$encoded_password" \
| ${pkgs.podman}/bin/podman exec -i openpost-postgres psql \
-v ON_ERROR_STOP=1 -U ${openpostPostgresUser} -d ${openpostPostgresDatabase} >/dev/null
${pkgs.podman}/bin/podman exec --env POSTGRES_PASSWORD openpost-postgres sh -ec \
'PGPASSWORD="$POSTGRES_PASSWORD" psql -h 127.0.0.1 -v ON_ERROR_STOP=1 -U ${openpostPostgresUser} -d ${openpostPostgresDatabase} -Atqc "SELECT 1"' \
| ${pkgs.gnugrep}/bin/grep -Fx 1 >/dev/null
'';
};
};
systemd.services.podman-openpost = {
after = lib.optionals isCloud [ "openpost-postgres-credential-reconcile.service" ];
requires = lib.optionals isCloud [ "openpost-postgres-credential-reconcile.service" ];
serviceConfig = {
ExecStop = lib.mkForce "${stopManagedContainer "openpost"}";
TimeoutStopSec = lib.mkForce 120;
};
unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ];
};
systemd.services.podman-openpost-worker = lib.mkIf isCloud {
after = [ "openpost-postgres-credential-reconcile.service" ];
requires = [ "openpost-postgres-credential-reconcile.service" ];
serviceConfig = {
ExecStop = lib.mkForce "${stopManagedContainer "openpost-worker"}";
TimeoutStopSec = lib.mkForce 120;
};
unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ];
};
systemd.services.podman-openpost-postgres.serviceConfig.ExecStop =
lib.mkForce "${stopManagedContainer "openpost-postgres"}";
systemd.services.openpost-postgres-backup = lib.mkIf isCloud {
description = "Backup OpenPost Postgres database";
unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ];
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";
unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ];
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-offsite-backup = lib.mkIf (isCloud && cfg.offsiteBackup.enable) {
description = "Encrypt and copy OpenPost backups and logs off host";
after = [
"openpost-postgres-backup.service"
"openpost-media-backup.service"
];
requires = [
"openpost-postgres-backup.service"
"openpost-media-backup.service"
];
unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ];
serviceConfig = {
Type = "oneshot";
UMask = "0077";
EnvironmentFile = config.sops.templates."openpost-offsite-backup-env".path;
CacheDirectory = "openpost-restic";
ExecStart = pkgs.writeShellScript "openpost-offsite-backup" ''
set -euo pipefail
backup_root=/var/backup/openpost
log_root="$backup_root/logs"
evidence_root=/var/lib/openpost
${pkgs.coreutils}/bin/mkdir -p "$log_root" "$evidence_root"
timestamp="$(${pkgs.coreutils}/bin/date -u +%Y%m%d_%H%M%S)"
log_archive="$log_root/openpost-journal-$timestamp.json.gz"
log_tmp="$(${pkgs.coreutils}/bin/mktemp "$log_root/.openpost-journal-$timestamp.XXXXXX")"
cleanup() {
${pkgs.coreutils}/bin/rm -f -- "$log_tmp"
}
trap cleanup EXIT
${pkgs.systemd}/bin/journalctl \
--since '25 hours ago' \
--output=json \
--unit=podman-openpost.service \
--unit=podman-openpost-worker.service \
--unit=podman-openpost-postgres.service \
--unit=openpost-postgres-backup.service \
--unit=openpost-media-backup.service \
--unit=openpost-restore-drill.service \
| ${pkgs.gzip}/bin/gzip > "$log_tmp"
${pkgs.gzip}/bin/gzip -t "$log_tmp"
${pkgs.coreutils}/bin/chmod 0600 "$log_tmp"
${pkgs.coreutils}/bin/mv "$log_tmp" "$log_archive"
trap - EXIT
${pkgs.findutils}/bin/find "$log_root" -type f -name 'openpost-journal-*.json.gz' -mtime +3 -delete
if ! ${pkgs.restic}/bin/restic cat config >/dev/null 2>&1; then
${pkgs.restic}/bin/restic init
fi
snapshot_id="$(${pkgs.restic}/bin/restic backup \
--json \
--host rgo-vps \
--tag openpost \
--exclude "$backup_root/media-versions" \
"$backup_root" \
| ${pkgs.jq}/bin/jq -r 'select(.message_type == "summary") | .snapshot_id' \
| ${pkgs.coreutils}/bin/tail -n 1)"
[[ "$snapshot_id" =~ ^[0-9a-f]{64}$ ]] || {
echo "Restic did not report an OpenPost snapshot ID" >&2
exit 1
}
${pkgs.restic}/bin/restic check --read-data-subset=5%
${pkgs.restic}/bin/restic forget \
--host rgo-vps \
--tag openpost \
--keep-daily 7 \
--keep-weekly 5 \
--keep-monthly 12 \
--prune
checked_at="$(${pkgs.coreutils}/bin/date -u +%Y-%m-%dT%H:%M:%SZ)"
evidence_tmp="$(${pkgs.coreutils}/bin/mktemp "$evidence_root/.offsite-backup-latest.XXXXXX")"
${pkgs.jq}/bin/jq -cn \
--arg status passed \
--arg checked_at "$checked_at" \
--arg snapshot_id "$snapshot_id" \
'{status: $status, checked_at: $checked_at, snapshot_id: $snapshot_id}' \
> "$evidence_tmp"
${pkgs.coreutils}/bin/chmod 0600 "$evidence_tmp"
${pkgs.coreutils}/bin/mv "$evidence_tmp" "$evidence_root/offsite-backup-latest.json"
'';
};
};
systemd.timers.openpost-offsite-backup = lib.mkIf (isCloud && cfg.offsiteBackup.enable) {
description = "Daily encrypted off-host OpenPost backup";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* 02:30:00";
Persistent = true;
RandomizedDelaySec = "10min";
};
};
systemd.services.openpost-restore-drill = lib.mkIf isCloud {
description =
if cfg.offsiteBackup.enable then
"Restore and validate the latest encrypted off-host OpenPost backup"
else
"Restore and validate the latest local OpenPost backup";
unitConfig.OnFailure = [ "openpost-ops-alert@%n.service" ];
after = [
"podman-openpost-postgres.service"
]
++ lib.optional cfg.offsiteBackup.enable "openpost-offsite-backup.service";
requires = [
"podman-openpost-postgres.service"
]
++ lib.optional cfg.offsiteBackup.enable "openpost-offsite-backup.service";
serviceConfig = {
Type = "oneshot";
UMask = "0077";
RuntimeDirectory = "openpost-restore-drill";
ExecStart = pkgs.writeShellScript "openpost-restore-drill" ''
set -euo pipefail
backup_root=/var/backup/openpost
offsite_restore_root=""
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
${lib.optionalString cfg.offsiteBackup.enable ''
if [ -n "$offsite_restore_root" ]; then
${pkgs.coreutils}/bin/rm -rf -- "$offsite_restore_root"
fi
''}
}
trap cleanup EXIT
${
if cfg.offsiteBackup.enable then
''
offsite_restore_root="$(${pkgs.coreutils}/bin/mktemp -d /run/openpost-restore-drill/offsite.XXXXXX)"
${pkgs.restic}/bin/restic restore latest \
--host rgo-vps \
--tag openpost \
--target "$offsite_restore_root" \
--include '/var/backup/openpost/openpost_*.sql.gz' \
--include '/var/backup/openpost/media-current/**'
backup_source_root="$offsite_restore_root/var/backup/openpost"
''
else
''
backup_source_root="$backup_root"
''
}
latest_backup=$(${pkgs.findutils}/bin/find "$backup_source_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 is available for the restore drill" >&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)"
${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")
restored_media_root="$backup_source_root/media-current"
if [ -d "$restored_media_root" ]; then
media_file_count=$(${pkgs.findutils}/bin/find "$restored_media_root" -type f | ${pkgs.coreutils}/bin/wc -l)
else
media_file_count=0
fi
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"
'';
}
// lib.optionalAttrs cfg.offsiteBackup.enable {
EnvironmentFile = config.sops.templates."openpost-offsite-backup-env".path;
CacheDirectory = "openpost-restic";
};
};
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;
};
}