add postiz + instagram-poster stacks for IG Stories pipeline

New stacks:
- stacks/postiz/ — Postiz scheduler (Helm chart v1.0.5, image v2.21.7)
  with bundled PG/Redis, /uploads PVC on proxmox-lvm, JWT_SECRET
  via ESO from secret/instagram-poster.
- stacks/instagram-poster/ — custom Python service that polls Immich
  for the 'instagram' tag, reformats photos to 9:16 with blurred-bg
  letterbox, exposes /image/<asset_id> publicly so Postiz can fetch.
  Image: forgejo.viktorbarzin.me/viktor/instagram-poster.

n8n: 3 new workflows (discover, approval, post) for the Telegram
inline-button approval UX. Adds ExternalSecret + env vars for
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, IMMICH_API_KEY, plus static
URLs for the new service.

Vault: seed secret/instagram-poster with telegram_bot_token,
telegram_chat_id, immich_api_key, postiz_api_token,
postiz_jwt_secret before applying.
This commit is contained in:
Viktor Barzin 2026-05-09 00:07:44 +00:00
parent badc341669
commit 73eb01f994
14 changed files with 1276 additions and 0 deletions

View file

@ -0,0 +1,17 @@
variable "tls_secret_name" {
type = string
sensitive = true
}
variable "image_tag" {
type = string
default = "latest"
description = "instagram-poster image tag. Use 8-char git SHA in CI; :latest only for local trials."
}
module "instagram_poster" {
source = "./modules/instagram-poster"
tier = local.tiers.aux
tls_secret_name = var.tls_secret_name
image_tag = var.image_tag
}

View file

@ -0,0 +1,275 @@
locals {
namespace = "instagram-poster"
# Forgejo registry consolidation (2026-05-07): all custom service images
# live under forgejo.viktorbarzin.me/viktor/<name>. The old 10.0.20.10
# private registry was decommissioned the same day.
image = "forgejo.viktorbarzin.me/viktor/instagram-poster:${var.image_tag}"
labels = {
app = "instagram-poster"
}
}
resource "kubernetes_namespace" "instagram_poster" {
metadata {
name = local.namespace
labels = {
tier = var.tier
"istio-injection" = "disabled"
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
}
}
# App secrets sourced from Vault KV `secret/instagram-poster`.
# Seed these manually in Vault before applying:
# secret/instagram-poster -> properties:
# - immich_api_key (required)
# - postiz_api_token (required)
# - immich_tag_instagram (optional auto-resolved if missing)
# - immich_tag_posted (optional auto-resolved if missing)
resource "kubernetes_manifest" "external_secret" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "instagram-poster-secrets"
namespace = local.namespace
}
spec = {
refreshInterval = "15m"
secretStoreRef = {
name = "vault-kv"
kind = "ClusterSecretStore"
}
target = {
name = "instagram-poster-secrets"
template = {
metadata = {
annotations = {
"reloader.stakater.com/match" = "true"
}
}
}
}
data = [
{
secretKey = "IMMICH_API_KEY"
remoteRef = {
key = "instagram-poster"
property = "immich_api_key"
}
},
{
secretKey = "POSTIZ_API_TOKEN"
remoteRef = {
key = "instagram-poster"
property = "postiz_api_token"
}
},
{
secretKey = "IMMICH_TAG_INSTAGRAM"
remoteRef = {
key = "instagram-poster"
property = "immich_tag_instagram"
}
},
{
secretKey = "IMMICH_TAG_POSTED"
remoteRef = {
key = "instagram-poster"
property = "immich_tag_posted"
}
},
]
}
}
depends_on = [kubernetes_namespace.instagram_poster]
}
# Persistent state: SQLite + image cache. Sensitive (API tokens may end up
# in cached images / debug logs), but the proxmox-lvm-encrypted SC is for
# user-data DBs; this is a small app cache so plain proxmox-lvm fits the
# infra/.claude/CLAUDE.md decision rule.
resource "kubernetes_persistent_volume_claim" "data" {
wait_until_bound = false
metadata {
name = "instagram-poster-data"
namespace = kubernetes_namespace.instagram_poster.metadata[0].name
annotations = {
"resize.topolvm.io/threshold" = "80%"
"resize.topolvm.io/increase" = "100%"
"resize.topolvm.io/storage_limit" = "20Gi"
}
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "proxmox-lvm"
resources {
requests = {
storage = "10Gi"
}
}
}
}
resource "kubernetes_deployment" "instagram_poster" {
metadata {
name = "instagram-poster"
namespace = kubernetes_namespace.instagram_poster.metadata[0].name
labels = merge(local.labels, {
tier = var.tier
})
annotations = {
"reloader.stakater.com/search" = "true"
}
}
spec {
replicas = 1
# RWO PVC cannot rolling-update.
strategy {
type = "Recreate"
}
selector {
match_labels = local.labels
}
template {
metadata {
labels = local.labels
annotations = {
# Diun watches this image tag and POSTs the auto-upgrade pipeline.
"diun.enable" = "true"
}
}
spec {
image_pull_secrets {
name = "registry-credentials"
}
container {
name = "instagram-poster"
image = local.image
port {
container_port = 8000
}
env_from {
secret_ref {
name = "instagram-poster-secrets"
}
}
env {
name = "IMMICH_BASE_URL"
value = "https://immich.viktorbarzin.me"
}
env {
name = "POSTIZ_BASE_URL"
value = "http://postiz.postiz.svc.cluster.local"
}
env {
name = "PUBLIC_BASE_URL"
value = "https://instagram-poster.viktorbarzin.me"
}
env {
name = "DATA_DIR"
value = "/data"
}
env {
name = "LOG_LEVEL"
value = "INFO"
}
volume_mount {
name = "data"
mount_path = "/data"
}
readiness_probe {
http_get {
path = "/healthz"
port = 8000
}
initial_delay_seconds = 5
period_seconds = 10
}
liveness_probe {
http_get {
path = "/healthz"
port = 8000
}
initial_delay_seconds = 15
period_seconds = 20
}
resources {
requests = {
cpu = "50m"
memory = "64Mi"
}
limits = {
memory = "512Mi"
}
}
}
volume {
name = "data"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.data.metadata[0].name
}
}
}
}
}
lifecycle {
ignore_changes = [spec[0].template[0].spec[0].dns_config] # KYVERNO_LIFECYCLE_V1
}
depends_on = [
kubernetes_manifest.external_secret,
]
}
resource "kubernetes_service" "instagram_poster" {
metadata {
name = "instagram-poster"
namespace = kubernetes_namespace.instagram_poster.metadata[0].name
labels = local.labels
}
spec {
type = "ClusterIP"
selector = local.labels
port {
name = "http"
port = 80
target_port = 8000
}
}
}
# Public ingress. No UI entire host is API-only and Meta needs to fetch
# /image/<asset_id> unauthenticated to render preview cards. We therefore
# leave `protected = false` so Authentik forward-auth doesn't run on any
# path. Inbound auth is the API's own concern (Postiz webhook signature
# / shared secret as configured by the parallel agent).
module "ingress" {
source = "../../../../modules/kubernetes/ingress_factory"
dns_type = "proxied"
namespace = kubernetes_namespace.instagram_poster.metadata[0].name
name = "instagram-poster"
tls_secret_name = var.tls_secret_name
protected = false
port = 80
}

View file

@ -0,0 +1,15 @@
variable "tls_secret_name" {
type = string
sensitive = true
}
variable "image_tag" {
type = string
default = "latest"
description = "instagram-poster image tag. Use 8-char git SHA in CI; :latest only for local trials."
}
variable "tier" {
type = string
default = "4-aux"
}

View file

@ -0,0 +1 @@
../../secrets

View file

@ -0,0 +1,23 @@
include "root" {
path = find_in_parent_folders()
}
dependency "platform" {
config_path = "../platform"
skip_outputs = true
}
dependency "vault" {
config_path = "../vault"
skip_outputs = true
}
dependency "external-secrets" {
config_path = "../external-secrets"
skip_outputs = true
}
inputs = {
# Bump per deploy. Use 8-char git SHA in CI; :latest only for local trials.
image_tag = "latest"
}

View file

@ -80,6 +80,44 @@ resource "kubernetes_manifest" "external_secret_claude_agent" {
depends_on = [kubernetes_namespace.n8n]
}
# Shared secrets for the Immich Telegram Postiz Instagram pipeline.
# Workflows in stacks/n8n/workflows/instagram-*.json reference these env vars.
resource "kubernetes_manifest" "external_secret_instagram_pipeline" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "instagram-pipeline-secrets"
namespace = "n8n"
}
spec = {
refreshInterval = "15m"
secretStoreRef = {
name = "vault-kv"
kind = "ClusterSecretStore"
}
target = {
name = "instagram-pipeline-secrets"
}
data = [
{
secretKey = "telegram_bot_token"
remoteRef = { key = "instagram-poster", property = "telegram_bot_token" }
},
{
secretKey = "telegram_chat_id"
remoteRef = { key = "instagram-poster", property = "telegram_chat_id" }
},
{
secretKey = "immich_api_key"
remoteRef = { key = "instagram-poster", property = "immich_api_key" }
},
]
}
}
depends_on = [kubernetes_namespace.n8n]
}
resource "kubernetes_persistent_volume_claim" "data_encrypted" {
wait_until_bound = false
metadata {
@ -253,6 +291,47 @@ resource "kubernetes_deployment" "n8n" {
name = "N8N_BLOCK_ENV_ACCESS_IN_NODE"
value = "false"
}
# Instagram pipeline env (consumed by workflows in
# stacks/n8n/workflows/instagram-*.json).
env {
name = "TELEGRAM_BOT_TOKEN"
value_from {
secret_key_ref {
name = "instagram-pipeline-secrets"
key = "telegram_bot_token"
}
}
}
env {
name = "TELEGRAM_CHAT_ID"
value_from {
secret_key_ref {
name = "instagram-pipeline-secrets"
key = "telegram_chat_id"
}
}
}
env {
name = "IMMICH_API_KEY"
value_from {
secret_key_ref {
name = "instagram-pipeline-secrets"
key = "immich_api_key"
}
}
}
env {
name = "IMMICH_BASE_URL"
value = "https://immich.viktorbarzin.me"
}
env {
name = "INSTAGRAM_POSTER_INTERNAL_URL"
value = "http://instagram-poster.instagram-poster.svc.cluster.local"
}
env {
name = "PUBLIC_INSTAGRAM_POSTER_URL"
value = "https://instagram-poster.viktorbarzin.me"
}
volume_mount {
name = "data"
mount_path = "/home/node/.n8n"

View file

@ -0,0 +1,269 @@
{
"name": "Instagram Approval",
"active": true,
"id": "483773c0-0b62-4ae5-b1b1-345f5df7b133",
"versionId": "483773c0-0b62-4ae5-b1b1-345f5df7b133",
"nodes": [
{
"parameters": {
"updates": ["callback_query"],
"additionalFields": {}
},
"id": "telegram-trigger",
"name": "Telegram callback_query",
"type": "n8n-nodes-base.telegramTrigger",
"typeVersion": 1.1,
"position": [250, 400],
"webhookId": "f2c7c254-ebaf-4f66-b1b4-5c1629c07e08",
"credentials": {
"telegramApi": {
"id": "telegram-bot",
"name": "Telegram Bot"
}
},
"notes": "Listens for inline-button taps. Requires a Telegram credential bound to the same bot whose token is in TELEGRAM_BOT_TOKEN."
},
{
"parameters": {
"jsCode": "const cb = $input.first().json.callback_query || {};\nconst data = cb.data || '';\nconst [action, assetId] = data.split(':');\nconst message = cb.message || {};\nconst chatId = (message.chat || {}).id;\nconst messageId = message.message_id;\nconst originalCaption = message.caption || '';\nconst callbackQueryId = cb.id;\n\nif (!action || !assetId) {\n throw new Error('Malformed callback_data: ' + data);\n}\n\nreturn [{\n json: {\n action,\n asset_id: assetId,\n chat_id: chatId,\n message_id: messageId,\n original_caption: originalCaption,\n callback_query_id: callbackQueryId\n }\n}];"
},
"id": "parse-callback",
"name": "Parse callback_data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [470, 400],
"notes": "Splits callback_data into action + asset_id, captures chat_id/message_id/caption for later edits and answerCallbackQuery."
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
"conditions": [{"id": "is-approve", "leftValue": "={{ $json.action }}", "rightValue": "approve", "operator": {"type": "string", "operation": "equals"}}],
"combinator": "and"
},
"outputKey": "approve"
},
{
"conditions": {
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
"conditions": [{"id": "is-reject", "leftValue": "={{ $json.action }}", "rightValue": "reject", "operator": {"type": "string", "operation": "equals"}}],
"combinator": "and"
},
"outputKey": "reject"
}
]
},
"options": {}
},
"id": "switch-action",
"name": "Switch on action",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [690, 400],
"notes": "Branches on action; unknown actions fall through and are dropped."
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.INSTAGRAM_POSTER_INTERNAL_URL }}/enqueue",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Authorization", "value": "=Bearer {{ $env.INSTAGRAM_POSTER_TOKEN }}"},
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ asset_id: $json.asset_id }) }}",
"options": {"timeout": 30000}
},
"id": "approve-enqueue",
"name": "Approve: enqueue asset",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [910, 250],
"onError": "continueErrorOutput",
"notes": "Calls instagram-poster /enqueue. continueErrorOutput so we can fall through to a Telegram error message on failure."
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.INSTAGRAM_POSTER_INTERNAL_URL }}/reject",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Authorization", "value": "=Bearer {{ $env.INSTAGRAM_POSTER_TOKEN }}"},
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ asset_id: $json.asset_id }) }}",
"options": {"timeout": 30000}
},
"id": "reject-mark",
"name": "Reject: mark seen",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [910, 550],
"onError": "continueErrorOutput",
"notes": "Calls instagram-poster /reject so the asset is recorded and not re-surfaced."
},
{
"parameters": {
"jsCode": "const upstream = $('Parse callback_data').item.json;\nconst suffix = '\\n\\nQueued for posting';\nreturn [{ json: { ...upstream, new_caption: (upstream.original_caption || '') + suffix } }];"
},
"id": "approve-caption",
"name": "Approve: build new caption",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1130, 250],
"notes": "Append a confirmation suffix to the original caption."
},
{
"parameters": {
"jsCode": "const upstream = $('Parse callback_data').item.json;\nconst suffix = '\\n\\nRejected';\nreturn [{ json: { ...upstream, new_caption: (upstream.original_caption || '') + suffix } }];"
},
"id": "reject-caption",
"name": "Reject: build new caption",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1130, 550],
"notes": "Append rejection suffix to the original caption."
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageCaption",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, message_id: $json.message_id, caption: $json.new_caption, parse_mode: 'HTML' }) }}",
"options": {"timeout": 30000}
},
"id": "edit-caption",
"name": "Telegram editMessageCaption",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1350, 400],
"notes": "Updates the original DM caption to show the resulting state. Strips inline buttons in the same call by omitting reply_markup combined with the next node."
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageReplyMarkup",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, message_id: $json.message_id, reply_markup: { inline_keyboard: [] } }) }}",
"options": {"timeout": 30000}
},
"id": "edit-reply-markup",
"name": "Telegram editMessageReplyMarkup",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1570, 400],
"notes": "Strips the inline approve/reject buttons so the original DM no longer offers them after a decision."
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ callback_query_id: $json.callback_query_id, text: 'Recorded' }) }}",
"options": {"timeout": 15000}
},
"id": "answer-callback",
"name": "Telegram answerCallbackQuery",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1790, 400],
"notes": "Dismisses the loading spinner on the user's tap."
},
{
"parameters": {
"jsCode": "const cb = $('Parse callback_data').item.json;\nconst err = $input.first().json.error || $input.first().json;\nconst msg = (err && (err.message || err.description || JSON.stringify(err))) || 'unknown error';\nreturn [{ json: { chat_id: cb.chat_id, text: 'Instagram poster error for ' + cb.asset_id + ':\\n' + msg } }];"
},
"id": "build-error-msg",
"name": "Build error message",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1130, 750],
"notes": "Catches non-2xx from /enqueue or /reject and formats a Telegram alert text."
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, text: $json.text }) }}",
"options": {"timeout": 15000}
},
"id": "telegram-error-msg",
"name": "Telegram error notice",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1350, 750],
"notes": "Sends the error text to the user as a follow-up message."
}
],
"connections": {
"Telegram callback_query": {"main": [[{"node": "Parse callback_data", "type": "main", "index": 0}]]},
"Parse callback_data": {"main": [[{"node": "Switch on action", "type": "main", "index": 0}]]},
"Switch on action": {
"main": [
[{"node": "Approve: enqueue asset", "type": "main", "index": 0}],
[{"node": "Reject: mark seen", "type": "main", "index": 0}]
]
},
"Approve: enqueue asset": {
"main": [
[{"node": "Approve: build new caption", "type": "main", "index": 0}],
[{"node": "Build error message", "type": "main", "index": 0}]
]
},
"Reject: mark seen": {
"main": [
[{"node": "Reject: build new caption", "type": "main", "index": 0}],
[{"node": "Build error message", "type": "main", "index": 0}]
]
},
"Approve: build new caption": {"main": [[{"node": "Telegram editMessageCaption", "type": "main", "index": 0}]]},
"Reject: build new caption": {"main": [[{"node": "Telegram editMessageCaption", "type": "main", "index": 0}]]},
"Telegram editMessageCaption": {"main": [[{"node": "Telegram editMessageReplyMarkup", "type": "main", "index": 0}]]},
"Telegram editMessageReplyMarkup": {"main": [[{"node": "Telegram answerCallbackQuery", "type": "main", "index": 0}]]},
"Build error message": {"main": [[{"node": "Telegram error notice", "type": "main", "index": 0}]]}
},
"settings": {"executionOrder": "v1", "saveExecutionProgress": false, "saveManualExecutions": true},
"staticData": null,
"meta": {"templateCredsSetupCompleted": false},
"pinData": {}
}

View file

@ -0,0 +1,132 @@
{
"name": "Instagram Discover",
"active": true,
"id": "3bae241e-c693-49aa-b271-51af0ec811dc",
"versionId": "3bae241e-c693-49aa-b271-51af0ec811dc",
"nodes": [
{
"parameters": {
"rule": {
"interval": [{"field": "minutes", "minutesInterval": 30}]
}
},
"id": "cron-30min",
"name": "Every 30 minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [250, 300],
"notes": "Trigger every 30 minutes. Polling cadence is conservative for a personal pipeline; matches instagram-poster scan rate."
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.INSTAGRAM_POSTER_INTERNAL_URL }}/scan",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Authorization", "value": "=Bearer {{ $env.INSTAGRAM_POSTER_TOKEN }}"},
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": false,
"options": {"timeout": 60000}
},
"id": "scan-immich",
"name": "Scan Immich for new candidates",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [500, 300],
"notes": "Calls instagram-poster /scan endpoint. Returns {\"new_items\": [\"asset-uuid\", ...]}. Authorization header is optional (internal cluster service); leave INSTAGRAM_POSTER_TOKEN blank if unused."
},
{
"parameters": {
"fieldToSplitOut": "new_items",
"options": {}
},
"id": "split-items",
"name": "Split new_items array",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [750, 300],
"notes": "Fan out one candidate per execution branch so each photo gets its own approval message."
},
{
"parameters": {
"batchSize": 1,
"options": {}
},
"id": "batch-loop",
"name": "Loop one at a time",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [970, 300],
"notes": "Process one asset at a time so errors on a single asset don't fan out and spam Telegram."
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.IMMICH_BASE_URL }}/api/assets/{{ $json.new_items }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "x-api-key", "value": "={{ $env.IMMICH_API_KEY }}"},
{"name": "Accept", "value": "application/json"}
]
},
"options": {"timeout": 30000}
},
"id": "fetch-asset-meta",
"name": "Fetch Immich asset metadata",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1190, 300],
"notes": "Pull asset metadata from Immich for caption preview (filename, fileCreatedAt, EXIF location)."
},
{
"parameters": {
"jsCode": "const item = $input.first().json;\nconst assetId = item.id || $('Loop one at a time').item.json.new_items;\nconst filename = item.originalFileName || item.originalPath || 'unknown';\nconst createdAt = item.fileCreatedAt || item.localDateTime || '';\nconst exif = item.exifInfo || {};\nconst city = exif.city || '';\nconst country = exif.country || '';\nconst location = [city, country].filter(Boolean).join(', ');\n\nconst dateStr = createdAt ? new Date(createdAt).toISOString().slice(0, 10) : '';\n\nconst lines = [\n '<b>New Immich candidate</b>',\n '',\n '<code>' + assetId + '</code>',\n '',\n '<b>File:</b> ' + filename\n];\nif (dateStr) lines.push('<b>Taken:</b> ' + dateStr);\nif (location) lines.push('<b>Where:</b> ' + location);\nlines.push('');\nlines.push('Approve to enqueue for posting, reject to mark seen.');\n\nreturn [{ json: { asset_id: assetId, caption: lines.join('\\n') } }];"
},
"id": "build-caption",
"name": "Build caption + asset id",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1410, 300],
"notes": "Compose the caption from Immich metadata. HTML parse_mode is used downstream."
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendPhoto",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $env.TELEGRAM_CHAT_ID, photo: $env.PUBLIC_INSTAGRAM_POSTER_URL + '/image/' + $json.asset_id, caption: $json.caption, parse_mode: 'HTML', reply_markup: { inline_keyboard: [[ { text: 'Approve', callback_data: 'approve:' + $json.asset_id }, { text: 'Reject', callback_data: 'reject:' + $json.asset_id } ]] } }) }}",
"options": {"timeout": 30000}
},
"id": "telegram-send-photo",
"name": "Telegram sendPhoto with buttons",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1630, 300],
"notes": "Telegram needs a public URL for the photo (it fetches the image from outside the cluster). callback_data uses the action:asset_id format consumed by instagram-approval."
}
],
"connections": {
"Every 30 minutes": {"main": [[{"node": "Scan Immich for new candidates", "type": "main", "index": 0}]]},
"Scan Immich for new candidates": {"main": [[{"node": "Split new_items array", "type": "main", "index": 0}]]},
"Split new_items array": {"main": [[{"node": "Loop one at a time", "type": "main", "index": 0}]]},
"Loop one at a time": {"main": [[{"node": "Fetch Immich asset metadata", "type": "main", "index": 0}]]},
"Fetch Immich asset metadata": {"main": [[{"node": "Build caption + asset id", "type": "main", "index": 0}]]},
"Build caption + asset id": {"main": [[{"node": "Telegram sendPhoto with buttons", "type": "main", "index": 0}]]},
"Telegram sendPhoto with buttons": {"main": [[{"node": "Loop one at a time", "type": "main", "index": 0}]]}
},
"settings": {"executionOrder": "v1", "saveExecutionProgress": false, "saveManualExecutions": true},
"staticData": null,
"meta": {"templateCredsSetupCompleted": false},
"pinData": {}
}

View file

@ -0,0 +1,177 @@
{
"name": "Instagram Post",
"active": true,
"id": "8964902b-b106-4cea-8965-77724baa71be",
"versionId": "8964902b-b106-4cea-8965-77724baa71be",
"nodes": [
{
"parameters": {
"rule": {
"interval": [{"field": "days", "daysInterval": 1, "triggerAtHour": 11, "triggerAtMinute": 0}]
}
},
"id": "cron-daily-11",
"name": "Daily 11:00 Europe/London",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [250, 300],
"notes": "Fires once a day. Postiz handles per-platform scheduling windows; this just feeds the next approved asset to the poster service."
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.INSTAGRAM_POSTER_INTERNAL_URL }}/post-next",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Authorization", "value": "=Bearer {{ $env.INSTAGRAM_POSTER_TOKEN }}"},
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": false,
"options": {
"timeout": 60000,
"response": {"response": {"fullResponse": true, "neverError": true}}
}
},
"id": "post-next",
"name": "Call /post-next",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [500, 300],
"notes": "neverError + fullResponse gives us the status code so we can branch on 200 / 404 / 5xx without throwing."
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
"conditions": [{"id": "is-200", "leftValue": "={{ $json.statusCode }}", "rightValue": 200, "operator": {"type": "number", "operation": "equals"}}],
"combinator": "and"
},
"outputKey": "ok"
},
{
"conditions": {
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
"conditions": [{"id": "is-404", "leftValue": "={{ $json.statusCode }}", "rightValue": 404, "operator": {"type": "number", "operation": "equals"}}],
"combinator": "and"
},
"outputKey": "empty"
},
{
"conditions": {
"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
"conditions": [{"id": "is-5xx", "leftValue": "={{ $json.statusCode }}", "rightValue": 500, "operator": {"type": "number", "operation": "largerEqual"}}],
"combinator": "and"
},
"outputKey": "error"
}
]
},
"options": {"fallbackOutput": "extra", "renameFallbackOutput": "other"}
},
"id": "switch-status",
"name": "Switch on status code",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [750, 300],
"notes": "200 -> success notify, 404 -> silent no-op, 5xx -> alert. Other 4xx falls into the fallback branch and is also alerted."
},
{
"parameters": {
"jsCode": "const body = $input.first().json.body || $input.first().json;\nconst assetId = (body && (body.asset_id || body.id)) || 'unknown';\nreturn [{ json: { chat_id: $env.TELEGRAM_CHAT_ID, text: 'Story scheduled: ' + assetId } }];"
},
"id": "build-success-msg",
"name": "Build success message",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1000, 150],
"notes": "Pulls asset_id out of the response body for the confirmation Telegram message."
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, text: $json.text }) }}",
"options": {"timeout": 15000}
},
"id": "telegram-success",
"name": "Telegram success notice",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1250, 150],
"notes": "Confirms the scheduled post to the user."
},
{
"parameters": {
"jsCode": "const r = $input.first().json;\nconst body = r.body || {};\nconst err = body.error || JSON.stringify(body) || ('HTTP ' + r.statusCode);\nreturn [{ json: { chat_id: $env.TELEGRAM_CHAT_ID, text: 'Instagram post-next failed (HTTP ' + r.statusCode + '): ' + err } }];"
},
"id": "build-error-msg",
"name": "Build error message",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1000, 450],
"notes": "Formats a Telegram alert with status code + body error message."
},
{
"parameters": {
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{"name": "Content-Type", "value": "application/json"}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, text: $json.text }) }}",
"options": {"timeout": 15000}
},
"id": "telegram-error",
"name": "Telegram error alert",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1250, 450],
"notes": "Sends the error message to the user."
},
{
"parameters": {},
"id": "noop-empty",
"name": "Empty queue (no-op)",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1000, 300],
"notes": "404 means there are no approved items waiting; do nothing instead of spamming Telegram."
}
],
"connections": {
"Daily 11:00 Europe/London": {"main": [[{"node": "Call /post-next", "type": "main", "index": 0}]]},
"Call /post-next": {"main": [[{"node": "Switch on status code", "type": "main", "index": 0}]]},
"Switch on status code": {
"main": [
[{"node": "Build success message", "type": "main", "index": 0}],
[{"node": "Empty queue (no-op)", "type": "main", "index": 0}],
[{"node": "Build error message", "type": "main", "index": 0}],
[{"node": "Build error message", "type": "main", "index": 0}]
]
},
"Build success message": {"main": [[{"node": "Telegram success notice", "type": "main", "index": 0}]]},
"Build error message": {"main": [[{"node": "Telegram error alert", "type": "main", "index": 0}]]}
},
"settings": {"executionOrder": "v1", "saveExecutionProgress": false, "saveManualExecutions": true},
"staticData": null,
"meta": {"templateCredsSetupCompleted": false},
"pinData": {}
}

11
stacks/postiz/main.tf Normal file
View file

@ -0,0 +1,11 @@
variable "tls_secret_name" {
type = string
sensitive = true
}
variable "nfs_server" { type = string }
module "postiz" {
source = "./modules/postiz"
tls_secret_name = var.tls_secret_name
tier = local.tiers.aux
}

View file

@ -0,0 +1,223 @@
#
# Postiz social media post scheduler (Instagram Stories + others).
#
# Chart: oci://ghcr.io/gitroomhq/postiz-helmchart/charts/postiz (v1.0.5)
# App : ghcr.io/gitroomhq/postiz-app:v2.21.7
#
# Layout:
# - Bundled Postgres + Redis (chart subcharts) fine for v1.
# - Local file storage for uploads on a `proxmox-lvm` PVC mounted at /uploads.
# - JWT_SECRET is sourced from Vault via ESO. The chart's helper-templated
# Secret name is `<release>-secrets`; we pin `fullnameOverride: postiz` so
# the Secret resolves to `postiz-secrets`. The chart already mounts that
# Secret via `envFrom: secretRef: <fullname>-secrets`, so ESO patching the
# same Secret with `creationPolicy: Merge` injects `JWT_SECRET` into the
# pod env without forking the chart.
# - OAuth credentials for Meta/X/LinkedIn etc. are NOT pre-seeded Postiz
# stores those in its own DB once the user adds providers via the UI.
#
resource "kubernetes_namespace" "postiz" {
metadata {
name = var.namespace
labels = {
tier = var.tier
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
}
}
module "tls_secret" {
source = "../../../../modules/kubernetes/setup_tls_secret"
namespace = kubernetes_namespace.postiz.metadata[0].name
tls_secret_name = var.tls_secret_name
}
# /uploads PVC keeps user-uploaded media across pod restarts.
resource "kubernetes_persistent_volume_claim" "uploads" {
wait_until_bound = false
metadata {
name = "postiz-uploads"
namespace = kubernetes_namespace.postiz.metadata[0].name
annotations = {
"resize.topolvm.io/threshold" = "80%"
"resize.topolvm.io/increase" = "100%"
"resize.topolvm.io/storage_limit" = "50Gi"
}
}
spec {
access_modes = ["ReadWriteOnce"]
storage_class_name = "proxmox-lvm"
resources {
requests = {
storage = var.storage_size
}
}
}
}
# ExternalSecret: patches the chart-managed `postiz-secrets` Secret with
# JWT_SECRET pulled from Vault. `creationPolicy: Merge` means ESO will not
# take ownership it just adds/updates the keys it manages, leaving the
# Helm-owned Secret resource intact. The chart's deployment already wires
# this Secret in via `envFrom: secretRef: postiz-secrets`.
resource "kubernetes_manifest" "external_secret_jwt" {
manifest = {
apiVersion = "external-secrets.io/v1beta1"
kind = "ExternalSecret"
metadata = {
name = "postiz-jwt-secret"
namespace = kubernetes_namespace.postiz.metadata[0].name
}
spec = {
refreshInterval = "15m"
secretStoreRef = {
name = "vault-kv"
kind = "ClusterSecretStore"
}
target = {
name = "postiz-secrets"
creationPolicy = "Merge"
}
data = [{
secretKey = "JWT_SECRET"
remoteRef = {
key = "instagram-poster"
property = "postiz_jwt_secret"
}
}]
}
}
depends_on = [kubernetes_namespace.postiz]
}
resource "helm_release" "postiz" {
namespace = kubernetes_namespace.postiz.metadata[0].name
name = "postiz"
create_namespace = false
atomic = true
timeout = 600
repository = "oci://ghcr.io/gitroomhq/postiz-helmchart/charts"
chart = "postiz"
version = var.chart_version
values = [yamlencode({
fullnameOverride = "postiz"
image = {
repository = "ghcr.io/gitroomhq/postiz-app"
tag = var.image_tag
pullPolicy = "IfNotPresent"
}
service = {
type = "ClusterIP"
port = 80 # chart maps Service port 80 -> targetPort http (containerPort 5000)
}
# Non-secret env. Note: BACKEND_INTERNAL_URL stays in-pod (Postiz convention).
env = {
MAIN_URL = "https://postiz.viktorbarzin.me"
FRONTEND_URL = "https://postiz.viktorbarzin.me"
NEXT_PUBLIC_BACKEND_URL = "https://postiz.viktorbarzin.me/api"
BACKEND_INTERNAL_URL = "http://localhost:3000"
STORAGE_PROVIDER = "local"
UPLOAD_DIRECTORY = "/uploads"
NEXT_PUBLIC_UPLOAD_DIRECTORY = "/uploads"
# Set true after first admin user is created via UI
DISABLE_REGISTRATION = "false"
IS_GENERAL = "true"
NX_ADD_PLUGINS = "false"
}
# Empty placeholder for chart-rendered Secret. ESO patches JWT_SECRET via
# creationPolicy=Merge above. DATABASE_URL/REDIS_URL are auto-wired by the
# chart's bundled subcharts and don't need to be set here.
secrets = {
DATABASE_URL = ""
REDIS_URL = ""
JWT_SECRET = ""
X_API_KEY = ""
X_API_SECRET = ""
LINKEDIN_CLIENT_ID = ""
LINKEDIN_CLIENT_SECRET = ""
REDDIT_CLIENT_ID = ""
REDDIT_CLIENT_SECRET = ""
GITHUB_CLIENT_ID = ""
GITHUB_CLIENT_SECRET = ""
RESEND_API_KEY = ""
CLOUDFLARE_ACCOUNT_ID = ""
CLOUDFLARE_ACCESS_KEY = ""
CLOUDFLARE_SECRET_ACCESS_KEY = ""
CLOUDFLARE_BUCKETNAME = ""
CLOUDFLARE_BUCKET_URL = ""
}
# Use our PVC for uploads (overrides the chart's emptyDir default).
extraVolumes = [{
name = "uploads-volume"
persistentVolumeClaim = {
claimName = kubernetes_persistent_volume_claim.uploads.metadata[0].name
}
}]
extraVolumeMounts = [{
name = "uploads-volume"
mountPath = "/uploads"
}]
resources = {
requests = {
cpu = "100m"
memory = "256Mi"
}
limits = {
memory = "2Gi"
}
}
# Bundled stateful deps fine for v1, reconsider promotion to CNPG later.
# Subchart passwords intentionally left to chart defaults; the bundled
# PG/Redis Services are ClusterIP and only routable from the postiz
# namespace, so the credentials never leave the pod network. Promotion to
# CNPG with Vault-rotated creds is the next step.
postgresql = {
enabled = true
auth = {
username = "postiz"
database = "postiz"
}
}
redis = {
enabled = true
}
})]
depends_on = [
kubernetes_persistent_volume_claim.uploads,
kubernetes_manifest.external_secret_jwt,
]
}
module "ingress" {
source = "../../../../modules/kubernetes/ingress_factory"
dns_type = "proxied"
namespace = kubernetes_namespace.postiz.metadata[0].name
name = "postiz"
host = var.host
service_name = "postiz" # chart Service name resolves to fullnameOverride
port = 80
tls_secret_name = var.tls_secret_name
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/name" = "Postiz"
"gethomepage.dev/description" = "Social media post scheduler"
"gethomepage.dev/icon" = "postiz.png"
"gethomepage.dev/group" = "Automation"
"gethomepage.dev/pod-selector" = ""
}
}

View file

@ -0,0 +1,40 @@
variable "tls_secret_name" {
type = string
sensitive = true
description = "Name of the wildcard TLS Secret to copy into the postiz namespace."
}
variable "tier" {
type = string
description = "Workload tier label applied to the namespace (e.g. 4-aux)."
}
variable "namespace" {
type = string
default = "postiz"
description = "Kubernetes namespace for Postiz."
}
variable "host" {
type = string
default = "postiz"
description = "Ingress hostname label (joined with root_domain by ingress_factory)."
}
variable "image_tag" {
type = string
default = "v2.21.7"
description = "Postiz container image tag."
}
variable "chart_version" {
type = string
default = "1.0.5"
description = "Postiz Helm chart version (OCI ghcr.io/gitroomhq/postiz-helmchart)."
}
variable "storage_size" {
type = string
default = "20Gi"
description = "Persistent volume size for /uploads."
}

1
stacks/postiz/secrets Symbolic link
View file

@ -0,0 +1 @@
../../secrets

View file

@ -0,0 +1,13 @@
include "root" {
path = find_in_parent_folders()
}
dependency "platform" {
config_path = "../platform"
skip_outputs = true
}
dependency "vault" {
config_path = "../vault"
skip_outputs = true
}