I wanted Postiz running on CapRover, but I did not want a one-off deploy I would regret the next time I needed to upgrade it.
That ruled out the usual copy-paste move: take the upstream Docker Compose, stuff everything into one opaque app, and hope future-you enjoys debugging it.
The better shape was to treat Postiz like a small production stack:
- split the services,
- pin the image tags,
- keep persistence explicit,
- and make rollback a normal operation instead of a panic move.
That approach worked.
The final deployment landed at:
https://postiz.cap.gregagi.com
With this app layout:
postizpostiz-dbpostiz-redispostiz-temporalpostiz-temporal-dbpostiz-temporal-elasticsearch
Why I did not deploy Postiz as one giant blob
A lot of self-hosted apps publish a Docker Compose example that is optimized for one thing: getting to “it starts” as fast as possible.
That is useful for local testing. It is not the same thing as a pleasant production setup.
I split Postiz into separate CapRover apps for three reasons.
1) Upgrades stay boring
If one service breaks on a version bump, I want to change or roll back that service directly. I do not want every operational change to feel like replacing the whole machine.
2) Stateful pieces stay explicit
Postgres, Redis, Temporal, and Elasticsearch do not all have the same operational role. Keeping them separate makes it much easier to understand what is durable, what is disposable, and what actually needs attention during incidents.
3) Debugging gets much faster
When a split stack fails, you can ask a narrow question:
- is the app booting,
- is Redis reachable,
- is Postgres up,
- is Temporal healthy,
- is Elasticsearch actually serving what Temporal expects?
That is a much better debugging surface than one huge container boundary.
The deployment shape I followed
I started with the official Postiz docs and repo, but only to understand the dependency graph. Not to copy the deployment shape blindly.
The important components were:
- the main Postiz app,
- a Postgres database for Postiz,
- Redis,
- Temporal,
- a separate Temporal database,
- and Elasticsearch for Temporal.
From there, I translated the stack into CapRover-native app boundaries instead of trying to preserve the exact Docker Compose ergonomics.
The general order was:
- create the backing services first,
- attach persistent storage where state mattered,
- wire internal service connectivity,
- deploy the public Postiz app last,
- verify behavior from the public URL instead of trusting container state alone.
That order matters.
If the public app fails before the dependencies are stable, you end up debugging too many moving pieces at once.
Where I used persistent volumes
Anything holding real state got persistent storage.
That meant the databases and other durable services were not depending on container lifetimes for survival.
This is one of the easiest ways to keep rollback realistic. Rolling back an image is simple. Recovering data because you treated a stateful service like a disposable container is not.
So the rule here was straightforward:
- stateless app containers can be replaced freely,
- stateful services need explicit persistence.
Nothing fancy. Just operator hygiene.
The first CapRover-specific gotcha: forceSsl
The first real issue was not Postiz itself. It was CapRover configuration ordering.
I initially tried to update the app definition with forceSsl enabled too early.
CapRover rejected that with the familiar status 1108 class of error, because the app did not yet have an SSL-enabled domain attached.
The rule is simple, but easy to trip over in automation:
- create the app,
- attach the domain,
- enable SSL for that domain,
- then enable
forceSsl.
If you try to collapse those into the wrong order, the update fails even though the desired end state is valid.
This is worth baking into every CapRover deploy workflow. Do not treat SSL enforcement as something you can always flip on in the first app-definition push.
Why I deployed dependencies before the public app
I brought up the backing services before asking Postiz to boot against them.
That sounds obvious, but it changes the quality of the debugging process.
Once Postiz started failing, I did not need to wonder whether the database existed, whether Redis was reachable, or whether the Temporal side was missing entirely. Those questions were already answered.
So the remaining problem space was smaller:
- either Postiz was misconfigured,
- or one of the already-deployed services was still unhealthy in a narrower way.
That is much easier to work with than a first deploy where every dependency is being created at the same time as the app.
The runtime failure that actually mattered
After the stack was structurally in place, the public app still was not healthy.
The symptom was clear:
- the backend was not properly serving
/auth, - and the process never cleanly bound to
:3000.
At that point, the right move was to stop guessing and read the live container logs.
That is where the real answer showed up.
The deployed Postiz image, ghcr.io/gitroomhq/postiz-app:v2.21.7, effectively behaved as if OPENAI_API_KEY was required during backend startup.
Without that env var, the backend never completed the startup path that exposed the HTTP service.
That is the kind of problem that is easy to misread from the outside. From the edge, it looks like:
- bad routing,
- broken reverse proxying,
- or a generic app crash.
But the real issue was earlier in the boot path.
Once I added OPENAI_API_KEY to the postiz app environment and redeployed, the service came up correctly.
I would still prefer a clean upstream switch that fully disables any MCP or AI-coupled startup path when it is not needed.
But operationally, the practical answer today was simple: if you are deploying this image, keep OPENAI_API_KEY present.
The verification I cared about before calling it done
I did not stop at “the container is running.”
The final checks were public-path checks:
/redirected to/auth,/authreturned200 OK,- and the main CapRover service for
postizwas running normally after the redeploy.
That is the minimum useful definition of success.
A surprising number of self-hosted deployments get called done when all that really happened was “Docker did not crash immediately.” That bar is too low.
If the app has a public login flow, test the public login route.
What I would recommend if you are self-hosting Postiz on CapRover
If I were doing this again, I would keep the same defaults.
Split the stack
Do not turn Postiz and all of its dependencies into one giant app boundary just because the upstream example is written that way.
Pin the image tags
Floating image references make self-hosted operations noisier than they need to be. Use explicit versions so upgrades are deliberate.
Bring up the backing services first
Let the app fail against a known-good dependency layer, not against a half-created one.
Respect CapRover’s SSL ordering
Domain and SSL first.
Then forceSsl.
Trust live startup logs over surface symptoms
If the app is not binding its HTTP port, read the boot path before you start rewriting proxy config.
Treat persistence as part of the deploy design, not an afterthought
Rollback plans are only real when state survives container replacement.
The larger lesson
The useful part of this Postiz deploy was not just that it works.
The useful part was ending up with a deployment shape that should stay maintainable:
- services are separated,
- images are pinned,
- state is explicit,
- rollback is practical,
- and debugging can happen one component at a time.
That is the difference between a self-hosted app you merely managed to start and a self-hosted app you can actually live with.
If you are running your own infra on CapRover, that difference matters more than shaving ten minutes off the first deploy.