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 c97f0497dd
commit 7a93e09d2f
No known key found for this signature in database
GPG key ID: 4056458DBDBF8863
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"
}