Postiz: operating and upgrading the social hub
Postiz is the self-hosted social distribution hub behind the opt-inpostiz
compose profile. Poindexter talks to it only through its public API
(services/integrations/postiz_client.py), and three things depend on that
API answering. Approving a social draft calls POST /public/v1/posts.
SyncPostizDeliveryStateJob and the brain’s postiz_queue_watch probe both
read GET /public/v1/posts.
The profile runs four containers:
Both images are pinned, never
:latest, and
tests/unit/services/test_postiz_compose_pins.py enforces that. The reasons are
below.
Diagnosing “API unreachable”
When the brain reportsPostiz queue wedged … API unreachable, first find out
whether the container is hung or the backend cannot boot. They need different
fixes, and a restart only fixes a hang.
backend inside the container, so Docker’s
RestartCount stays at 0 and the brain’s container restart-loop watch cannot
see the loop. The pm2 restart count and backend-error.log can. docker logs
interleaves the orchestrator’s very chatty output with everything else, so read
the pm2 log directly.
Failure: tables can have at most 1600 columns
Symptom
The backend exits on every boot withMASTRA_STORAGE_PG_ALTER_TABLE_FAILED / tables can have at most 1600 columns
(SQLSTATE 54011), naming mastra_ai_spans. The frontend and orchestrator
start normally, the container stays up but reports unhealthy, and the API does
not answer. Restarting changes nothing.
Cause
The container entrypoint runsprisma db push --accept-data-loss against
Postiz’s Prisma schema before starting the app. The backend then initialises
its Mastra storage (Postiz’s AI-agent layer), which adds any column its own
schema has and the table lacks.
Up to postiz-app v2.23.0, the two schemas disagreed. Prisma’s
mastra_ai_spans model had 21 columns, while the bundled Mastra (@mastra/core
1.21.0) wanted 43. Every backend boot added 22 columns, and every container
start dropped them again. mastra_scorers churned one column the same way.
PostgreSQL keeps a dropped column in pg_attribute for good, and dropped
columns count toward the 1600-column limit
(Appendix K). VACUUM FULL
does not give the slots back. So every restart spent 22 of the table’s 1600
slots. After about 72 restarts the next ADD COLUMN failed and the backend
could not boot. Each restart before that, including restarts made to “heal” the
container, brought the failure closer.
v2.24.0 is the first release whose Prisma models match its Mastra schemas, and
it is the floor that the pin test enforces. First seen 2026-09-28
(Glad-Labs/poindexter#1091): 21 live columns and 1579 dropped ones.
Check the headroom
Run againstpostiz-db. A healthy table has a dropped count that stays the
same across restarts.
Recover
Recreating the table is the only way to get the slots back. Back up first:mastra_ai_spans holds Mastra’s AI-agent traces and stays empty unless
Postiz’s AI features are in use. If it is empty, drop it and restart.
prisma db push and Mastra recreate it with every slot free:
INCLUDING ALL recreates the indexes under generated names. The next
prisma db push restores the names Prisma expects. On a version below v2.24.0
the leak resumes after either recovery. Upgrade as described next.
Upgrading Postiz
- Read the release’s upgrade notes for new required environment variables and Temporal workflow changes.
-
Check the new image’s schemas. The check must print
OKand exit 0:It reads Mastra’s table schemas and Postiz’s Prisma models out of the image and lists every column the two disagree on. That is the churn that spends attribute slots on every restart. It exits 1 on drift and 2 when it could not compare anything, for example when the image layout moved. Exit 2 is not a pass. For comparison, v2.21.10 fails with 22 columns onmastra_ai_spansand one onmastra_scorers. v2.24.0 passes across 43 tables. -
Back up
postiz-db(command above). -
For a large jump, replay the upgrade in a sandbox before touching the live
stack:
- Restore the dump into a throwaway
postgres:16-alpineon a network created withdocker network create --internal, so nothing inside it can reach a social platform. - Scrub the OAuth tokens in the copy. Using a copied refresh token can revoke
the live one:
UPDATE "Integration" SET token = 'scrubbed', "refreshToken" = 'scrubbed'; - Start the new image against it, with a throwaway Redis and Temporal (see the Temporal section below).
- Confirm the backend answers. Replay
PostizClient’s calls with the org API key from the copy’sOrganization."apiKey":GET /public/v1/posts,GET /public/v1/integrations, and aPOST /public/v1/posts(it will end inERRORbecause there is no network). Restart the container twice and re-run the headroom query.droppedmust not move.
- Restore the dump into a throwaway
-
Move the tag in both
docker-compose.local.ymlanddocker-compose.consumer.yml. The pin test requires both to match, because they share thegladlabs-postiz-*volumes. Merge. The deploy recreates the container, and the new image’sprisma db pushmigrates the database on start. -
Verify on the live stack: the container is healthy; the next brain heartbeat
shows
postiz_queue_watchasok(audit_log,event_type = 'brain.cycle_heartbeat',details->'probe_status'->>'postiz_queue_watch'); and the headroom query shows the samedroppedcount before and after one restart.
Temporal: search attributes on a fresh namespace
At boot the Postiz backend registers two custom search attributes of type Text,organizationId and postId, if they are missing. SQL visibility (our
postgres12 setup) allows a namespace only 3 Text attributes. Upstream’s own
compose runs Elasticsearch visibility, which has no such limit.
When temporalio/auto-setup creates a new namespace, it also registers demo
attributes, two of them Text (CustomStringField, CustomTextField). That
leaves one Text slot, so Postiz’s registration fails and the backend dies on
boot:
postiz-temporal sets SKIP_ADD_CUSTOM_SEARCH_ATTRIBUTES=true so that new
namespaces never get the demo attributes. A namespace that already has them can
be repaired in place:
temporal operator search-attribute list --address $(hostname -i):7233 inside
poindexter-postiz-temporal. organizationId and postId should appear as
Text.