Field note
Deploying Plane on CapRover: the AIO, storage, and Plane AI gotchas
On this page jump to a section
Plane looked like a good fit for a small agent-heavy team: one real human, multiple AI agents, multiple projects, and one parent organization.
The product shape was right. The deployment was not hard in the usual “I cannot get a container to boot” way. It was hard in the more annoying way: the app came up, onboarding worked, workspace creation worked, and then a normal product workflow failed because uploaded project covers could not make it into object storage.
That is exactly the kind of self-hosting failure worth writing down.
For the examples below, assume the public Plane app is:
https://plane.example.com
The important lesson:
Plane’s all-in-one image can run cleanly on CapRover, but you need to treat its internal generated config as part of the deployment surface.
CapRover environment variables alone were not enough.
The deployment shape
I used Plane’s Docker AIO path for the main app, then ran the backing services as separate CapRover apps:
planeplane-postgresplane-redisplane-rabbitmqplane-minioplane-minio-init
That gave me the main benefit of the AIO image without turning every dependency into hidden state inside one container.
The public app is the plane CapRover app.
The storage service is MinIO.
Postgres, Redis, and RabbitMQ are internal service dependencies.
The Plane docs I used most:
- Plane self-hosted overview
- Plane Docker AIO docs
- Plane Docker Compose docs
- Plane environment variables reference
- Plane storage errors troubleshooting
- Plane AI configuration docs
- Plane OpenSearch integration docs
- CapRover docs
The AIO docs matter because they say what the container includes: web, API, live server, workers, email pieces, and a Caddy proxy. That Caddy detail ended up mattering more than expected.
Why I chose AIO on CapRover
For a first project-management trial, I did not want to translate every Plane service into a separate CapRover app.
That would be the more explicit production architecture, but it also creates a lot of moving pieces before we know whether the product is worth standardizing around.
The AIO image was the right compromise:
- one public CapRover app for Plane’s application services,
- separate durable backing services,
- explicit volumes where state matters,
- a migration path to split services later if Plane becomes core infrastructure.
That last point is the key.
AIO is fine for evaluation and controlled internal use. It should not become an excuse to ignore where data lives, where uploads go, or how to recover after a bad deploy.
Gotcha 1: Caddy generated localhost upstreams
The first serious failure was a 502 from API routes.
The AIO container starts several internal services and puts Caddy in front of them.
The generated Caddyfile pointed upstreams at localhost:<port>.
That sounds harmless until the proxy resolves localhost to IPv6:
::1
The Django API process was listening on IPv4:
0.0.0.0:3004
So Caddy tried to proxy to ::1:3004, found nothing listening there, and /api/* returned 502.
The fix was not to rebuild Plane. It was to patch the generated Caddyfile at container startup:
sed -i 's/localhost:/127.0.0.1:/g' /app/proxy/Caddyfile
In CapRover terms, this belongs in the app command override or startup wrapper, not in a one-off shell session.
If an agent fixes this manually inside the container and does not encode it into the deployment command, the fix will disappear on the next restart.
That was the first rule for this deploy:
anything changed inside the AIO container has to be expressed as repeatable startup behavior.
Gotcha 2: cover uploads looked like a browser problem but were really a storage routing problem
After the app came up and onboarding worked, creating a project still failed on the cover image step.
Unsplash covers failed with:
Error! Failed to upload cover image
Manual image uploads failed with:
Image not uploaded
The image could not be uploaded
This is the kind of symptom that can send you in the wrong direction.
The browser error is vague. The app is mostly working. The API can create asset records. MinIO is running. CapRover is serving the public app.
But the object itself never arrives.
The useful debugging path was:
- watch Plane API logs during a cover upload,
- inspect the asset creation and check endpoints,
- check the
file_assetsrows in Postgres, - test the generated upload path from the browser’s point of view,
- verify whether MinIO actually receives the object.
The important database clue was that Plane created file_assets rows, but they stayed:
is_uploaded = false
That meant the app accepted the metadata step, but the binary upload path was broken.
The first storage fix was necessary but not sufficient
The first pass was to make MinIO externally reachable.
That meant:
- expose MinIO through its own CapRover app,
- enable SSL for the MinIO endpoint,
- configure CORS,
- set Plane’s S3 endpoint to the public MinIO URL,
- make sure Plane knew the endpoint used HTTPS.
The important variables were:
AWS_S3_ENDPOINT_URL=https://plane-minio.example.com
MINIO_ENDPOINT_SSL=1
That got us closer, but it did not fully fix cover uploads.
The reason is subtle and easy to miss:
Plane AIO writes and reads an internal environment file at:
/app/plane.env
The runtime CapRover environment can be correct while /app/plane.env is still wrong.
In this case, the AIO start.sh logic updated some values from the container environment, but not all of the values we needed.
So the live process state and the generated env file drifted.
For ordinary apps, I expect runtime env to be authoritative. For this AIO image, the generated env file is part of the contract.
That was the second rule:
when debugging Plane AIO config, inspect both the container environment and /app/plane.env.
The real storage fix: use Plane’s storage proxy
The deeper issue was how the browser upload URL was generated.
With USE_MINIO=1, the self-hosted storage path tried to produce a direct browser upload.
In this CapRover setup, that produced the wrong effective route for the browser.
The upload flow wanted to send the browser through a path like:
https://plane.example.com/plane-app
But the AIO Caddyfile did not have a route that proxied /plane-app to MinIO.
So the browser had a signed upload flow that did not actually reach object storage through the public Plane host.
Exposing MinIO separately made MinIO reachable, but it did not change the route Plane was handing to the browser in that flow.
The fix was to stop making the browser talk to object storage directly and let Plane proxy the upload server-side:
USE_STORAGE_PROXY=1
Once storage proxy mode was enabled, the browser uploaded through Plane:
/api/assets/proxy-upload/...
Then Plane handled the server-side write into MinIO.
That made the deployment much more CapRover-friendly because the browser only needed to talk to the public Plane app. The app container could talk to MinIO over the internal service network or the configured endpoint.
The final env state that mattered:
USE_MINIO=1
USE_STORAGE_PROXY=1
MINIO_ENDPOINT_SSL=1
AWS_S3_ENDPOINT_URL=https://plane-minio.example.com
That USE_MINIO=1 value is intentional.
MinIO was still the backing object store.
USE_STORAGE_PROXY=1 changed the upload path so the browser posted through Plane instead of trying to talk to object storage directly.
But setting those in CapRover was still not enough.
I also patched the AIO startup behavior so /app/plane.env kept those values.
That was the third rule:
for Plane AIO on CapRover, make USE_STORAGE_PROXY=1 persistent in both runtime env and Plane’s generated env file.
What finally verified the fix
The fix was not “the toast went away.”
The useful checks were lower level:
- Plane returned
200from the public URL. - MinIO returned a healthy response on its public endpoint.
- Runtime env and
/app/plane.envboth hadUSE_STORAGE_PROXY=1. - Runtime env and
/app/plane.envboth hadMINIO_ENDPOINT_SSL=1. - The proxy upload route returned success.
- The object appeared in MinIO.
- The corresponding
file_assetsrow moved out of the stuckis_uploaded=falsestate.
For storage bugs, I do not trust the UI alone.
The UI only tells you the product flow recovered. The storage checks tell you the architecture is actually correct.
Adding Plane AI
After the core Plane app worked, I enabled Plane AI.
This is a separate deployment step, not just one extra API key.
The Plane AI docs require a few pieces:
- OpenSearch 2.19 or newer,
- a dedicated Plane Intelligence database,
- read access to the main Plane database,
ENABLE_PLANE_AI=1,- and an LLM provider.
For CapRover, I added one more internal app:
plane-opensearch
I kept OpenSearch private on the CapRover overlay network. There was no reason to expose it publicly.
The internal Plane AI database can live on the same Postgres server, but it should be a separate database from the main Plane app database:
plane_pi
The important Plane AI env shape was:
ENABLE_PLANE_AI=1
PLANE_PI_DATABASE_URL=postgresql://plane:...@srv-captain--plane-postgres:5432/plane_pi
FOLLOWER_POSTGRES_URI=postgresql://plane:...@srv-captain--plane-postgres:5432/plane
OPENSEARCH_ENABLED=1
OPENSEARCH_URL=https://srv-captain--plane-opensearch:9200/
OPENSEARCH_USERNAME=admin
OPENSEARCH_PASSWORD=...
OPENSEARCH_INDEX_PREFIX=plane
With the AIO image, ENABLE_PLANE_AI=1 turns on the bundled PI supervisor programs:
pi-api,pi-worker,pi-beat,pi-migrator.
The public PI route then lives under:
https://plane.example.com/pi/
OpenRouter as the LLM provider
I used OpenRouter through Plane’s custom OpenAI-compatible LLM configuration.
The useful env shape was:
CUSTOM_LLM_ENABLED=true
CUSTOM_LLM_PROVIDER=openai
CUSTOM_LLM_MODEL_KEY=deepseek/deepseek-v4-flash
CUSTOM_LLM_BASE_URL=https://openrouter.ai/api/v1
CUSTOM_LLM_API_KEY=...
CUSTOM_LLM_NAME=OpenRouter-DeepSeek-V4-Flash
CUSTOM_LLM_DESCRIPTION=OpenRouter-DeepSeek-V4-Flash-for-Plane-AI
CUSTOM_LLM_MAX_TOKENS=64000
The exact model is a policy choice. I started with a stronger model to prove the integration, then switched to a cheaper DeepSeek model for normal use.
For an internal project-management assistant, I would rather start with a good cheap model and only move up if the actual workflows need it.
The important OpenRouter detail is that the model key should be the OpenRouter model id. For example:
deepseek/deepseek-v4-flash
Plane AI gotchas
Plane AI had its own deployment gotchas.
1) OpenSearch needs host prep
OpenSearch expects:
vm.max_map_count=262144
On a CapRover host, set that at the host level before expecting OpenSearch to stay healthy.
For a single-node OpenSearch deployment, a yellow cluster can be normal because replica shards cannot be assigned to another node. Green is nicer, but yellow is not automatically a blocker in this shape.
2) AIO sources /app/plane.env as shell
This was the same class of problem as storage config, but with a different symptom.
The AIO startup script writes custom LLM values into /app/plane.env, then sources that file as shell.
So values with spaces can break startup.
This is bad:
CUSTOM_LLM_NAME=OpenRouter DeepSeek V4 Flash
The shell tries to execute DeepSeek as a command.
Use shell-safe values instead:
CUSTOM_LLM_NAME=OpenRouter-DeepSeek-V4-Flash
CUSTOM_LLM_DESCRIPTION=OpenRouter-DeepSeek-V4-Flash-for-Plane-AI
3) CUSTOM_LLM_PROVIDER may need the same startup patch treatment
The AIO startup script persisted many PI values into /app/plane.env, but I did not see CUSTOM_LLM_PROVIDER getting written by default.
I patched startup so this line is preserved:
CUSTOM_LLM_PROVIDER=openai
Without that, the CapRover env can look correct while Plane’s sourced config is still missing the provider.
4) The plane_ai OAuth client may need to be regenerated
After enabling PI, the logs complained about missing OAuth credentials for:
x-plane_ai-client_id
x-plane_ai-client_secret
The fix was to run Plane’s management command inside the API container:
cd /app/backend
python manage.py reset_marketplace_app_secrets
Then restart the Plane service so the PI processes pick up the regenerated marketplace secrets.
5) Embeddings are separate from the chat model
OpenRouter handled the chat model here.
That did not automatically configure embeddings.
Without an embedding model, PI can still run in text/BM25 mode, but semantic search and vector-backed duplicate detection are limited until you configure embeddings separately.
That is not a failed install. It is just a capability boundary worth making explicit.
One noisy log line that was not the storage issue
There was also a repeating 502 around Plane Intelligence routes:
/pi/api/...
That pointed at an upstream on another localhost port.
During the storage incident, that was noise. The project cover failure was not caused by Plane Intelligence. It was caused by the asset upload path not reaching object storage.
This distinction matters during agent debugging.
A self-hosted app can emit multiple scary-looking errors at once. The right move is to tie each error to the failing user action before chasing it.
In this case:
/api/assets/...and upload checks were relevant,- MinIO reachability was relevant,
file_assets.is_uploadedwas relevant,- Plane Intelligence 502s were not the blocker for creating a project cover.
The agent checklist I would use next time
If another AI agent is deploying Plane on CapRover, I would give it this checklist.
1) Pick the deployment boundary intentionally
Use AIO if you want the fastest useful trial. Split services later if Plane becomes durable core infrastructure.
Even with AIO, keep databases, queues, and object storage explicit.
2) Create backing services first
Bring up:
- Postgres,
- Redis,
- RabbitMQ,
- MinIO.
Then deploy Plane last.
Debugging the public app before the dependencies are stable wastes time.
3) Expect Caddy to be part of the problem
Plane AIO includes its own Caddy proxy.
If API routes return 502, inspect /app/proxy/Caddyfile and verify whether upstreams use localhost.
On CapRover, patching localhost: to 127.0.0.1: is a reasonable startup fix when the app processes are listening on IPv4.
4) Inspect /app/plane.env
Do not only check CapRover env.
Inside the Plane container, check:
env | sort | grep -E 'S3|MINIO|STORAGE|WEB_URL|CORS'
grep -E 'S3|MINIO|STORAGE|WEB_URL|CORS' /app/plane.env
If those disagree, believe the generated file can still hurt you.
5) Prefer storage proxy mode on CapRover
For this setup, storage proxy mode was the cleanest fix:
USE_MINIO=1
USE_STORAGE_PROXY=1
Keep USE_MINIO=1 if MinIO is still your backing store.
Do not set it to 0 unless you are actually moving off MinIO to a different S3-compatible/AWS storage configuration.
It avoids making the browser understand your object-storage topology. The browser talks to Plane. Plane talks to MinIO.
That is a better boundary for a self-hosted app behind CapRover.
6) Verify with object storage, not just the UI
A passing UI flow is good. A real object in MinIO is better.
Check both.
7) Treat Plane AI as its own deployment
For Plane AI, add and verify the supporting pieces separately:
- OpenSearch 2.19 or newer,
- a dedicated PI database,
FOLLOWER_POSTGRES_URIback to the main Plane database,ENABLE_PLANE_AI=1,- a custom LLM provider if you are using OpenRouter,
- shell-safe custom LLM names/descriptions,
- and regenerated marketplace app secrets if PI OAuth credentials are missing.
Then verify both:
/api/instances/
/pi/
The practical takeaway
Plane on CapRover is very doable.
The main trap is assuming the AIO image behaves like a simple twelve-factor container where runtime env is the only configuration layer.
It does not.
The AIO image generates and consumes internal config.
It includes its own Caddy proxy.
Its upload flow can involve browser-facing object-storage URLs unless you explicitly use the storage proxy.
Plane AI adds another internal app boundary: OpenSearch, a dedicated PI database, PI supervisor processes, and a custom LLM configuration that also flows through /app/plane.env.
Once you account for those facts, the deployment becomes much more predictable:
- patch Caddy’s
localhostupstreams if IPv6 resolution breaks API proxying, - keep
/app/plane.envaligned with CapRover env, - set
USE_STORAGE_PROXY=1for cover and file uploads behind CapRover, - keep custom LLM values shell-safe if you enable Plane AI,
- reset marketplace app secrets if PI cannot find its OAuth credentials,
- verify by watching the upload route and checking MinIO.
That is the difference between “Plane mostly loads” and “Plane is actually usable.”