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;
};
}