If your team is paying $5,000–$6,000 a year for project management software and you're not using half its features, this guide is for you.
Plane is an open-source project management tool that looks and feels like a modern SaaS product — boards, cycles, modules, timelines, dashboards — but runs entirely on your own server, for free. No per-seat pricing. No vendor lock-in. Your data stays yours.
This tutorial walks you through deploying Plane's Community Edition on a server running Coolify — a self-hosted platform that makes deploying Docker-based apps straightforward, even if you've never touched a terminal before.
What you'll end up with: A fully working Plane instance accessible at your own domain (e.g.
plane.yourdomain.com), with SSL, running completely free on your own infrastructure.
Prerequisites
Before you start, make sure you have the following in place:
- A server running Coolify. This guide assumes Coolify is already installed and accessible. If not, follow the official Coolify installation guide first.
-
A domain name with access to your DNS settings. You'll need to create an A record pointing a subdomain (e.g.
plane.yourdomain.com) to your server's IP address. If you're unsure how to do this, refer to Coolify's DNS configuration guide. - SSH access to your server. You'll need to run a few commands directly on the server. Coolify's built-in terminal (under Servers → Terminal) works fine if you don't have a separate SSH client.
- Server specs: Minimum 2 CPU cores and 4 GB RAM.
Step 1 — Point Your Domain to Your Server
Before anything else, create a DNS A record for the subdomain you want to use.
- Log in to your domain registrar or DNS provider (e.g. Cloudflare, Namecheap, GoDaddy).
- Create an A record:
-
Name:
plane(or whatever subdomain you want) - Value: Your server's public IP address
- TTL: Auto or 300
-
Name:
To find your server's public IP, SSH into your server and run:
curl -4 ifconfig.me
The output is your server's public IP. Use that as the value for your A record.
DNS changes can take a few minutes to an hour to propagate. You can continue with the rest of the setup while you wait.
Step 2 — Gather Your Server's Docker Network Information
Later in this guide, you'll need to connect Coolify's built-in proxy (Traefik) to Plane's Docker network. To do this, you need two pieces of information from your server: your Docker gateway IP and the ability to identify Plane's Docker network ID after deployment.
Run this command to find the Docker gateway IP for the Coolify network:
sudo docker network inspect coolify | grep Gateway
You'll see output like:
"Gateway": "10.0.2.1"
Write this IP down. You'll need it later when configuring the Traefik proxy rule.
Step 3 — Create the Docker Compose File
In Coolify, navigate to your Project → New Resource → Docker Compose Empty.
Paste the following compose file exactly as shown. This is a production-ready configuration with Plane's proxy service binding to port 8090 on the host (instead of the default 80/443), so it doesn't conflict with Coolify's own proxy which already owns those ports.
x-db-env: &db-env
PGHOST: ${PGHOST:-plane-db}
PGDATABASE: ${PGDATABASE:-plane}
POSTGRES_USER: ${POSTGRES_USER:-plane}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plane}
POSTGRES_DB: ${POSTGRES_DB:-plane}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
PGDATA: ${PGDATA:-/var/lib/postgresql/data}
x-redis-env: &redis-env
REDIS_HOST: ${REDIS_HOST:-plane-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_URL: ${REDIS_URL:-redis://plane-redis:6379/}
x-minio-env: &minio-env
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key}
x-aws-s3-env: &aws-s3-env
AWS_REGION: ${AWS_REGION:-}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-secret-key}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
x-proxy-env: &proxy-env
APP_DOMAIN: ${APP_DOMAIN:-plane.yourdomain.com}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
CERT_EMAIL: ${CERT_EMAIL:-}
CERT_ACME_CA: ${CERT_ACME_CA:-}
CERT_ACME_DNS: ${CERT_ACME_DNS:-}
LISTEN_HTTP_PORT: ${LISTEN_HTTP_PORT:-8090}
LISTEN_HTTPS_PORT: ${LISTEN_HTTPS_PORT:-443}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
SITE_ADDRESS: ${SITE_ADDRESS:-:80}
x-mq-env: &mq-env
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-plane}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-plane}
RABBITMQ_VHOST: ${RABBITMQ_VHOST:-plane}
x-live-env: &live-env
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY}
x-app-env: &app-env
WEB_URL: ${WEB_URL:-https://plane.yourdomain.com}
DEBUG: ${DEBUG:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-https://plane.yourdomain.com}
GUNICORN_WORKERS: 1
USE_MINIO: ${USE_MINIO:-1}
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
SECRET_KEY: ${SECRET_KEY}
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY}
services:
web:
image: makeplane/plane-frontend:${APP_RELEASE:-stable}
deploy:
replicas: ${WEB_REPLICAS:-1}
restart_policy:
condition: any
depends_on:
- api
- worker
space:
image: makeplane/plane-space:${APP_RELEASE:-stable}
deploy:
replicas: ${SPACE_REPLICAS:-1}
restart_policy:
condition: any
depends_on:
- api
- worker
- web
admin:
image: makeplane/plane-admin:${APP_RELEASE:-stable}
deploy:
replicas: ${ADMIN_REPLICAS:-1}
restart_policy:
condition: any
depends_on:
- api
- web
live:
image: makeplane/plane-live:${APP_RELEASE:-stable}
environment:
<<: [*live-env, *redis-env]
deploy:
replicas: ${LIVE_REPLICAS:-1}
restart_policy:
condition: any
depends_on:
- api
- web
api:
image: makeplane/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-api.sh
deploy:
replicas: ${API_REPLICAS:-1}
restart_policy:
condition: any
volumes:
- logs_api:/code/plane/logs
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- plane-db
- plane-redis
- plane-mq
worker:
image: makeplane/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-worker.sh
deploy:
replicas: ${WORKER_REPLICAS:-1}
restart_policy:
condition: any
volumes:
- logs_worker:/code/plane/logs
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- api
- plane-db
- plane-redis
- plane-mq
beat-worker:
image: makeplane/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-beat.sh
deploy:
replicas: ${BEAT_WORKER_REPLICAS:-1}
restart_policy:
condition: any
volumes:
- logs_beat-worker:/code/plane/logs
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- api
- plane-db
- plane-redis
- plane-mq
migrator:
image: makeplane/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-migrator.sh
deploy:
replicas: 1
restart_policy:
condition: on-failure
volumes:
- logs_migrator:/code/plane/logs
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- plane-db
- plane-redis
plane-db:
image: postgres:15.7-alpine
command: postgres -c 'max_connections=1000'
deploy:
replicas: 1
restart_policy:
condition: any
environment:
<<: *db-env
volumes:
- pgdata:/var/lib/postgresql/data
plane-redis:
image: valkey/valkey:7.2.11-alpine
deploy:
replicas: 1
restart_policy:
condition: any
volumes:
- redisdata:/data
plane-mq:
image: rabbitmq:3.13.6-management-alpine
deploy:
replicas: 1
restart_policy:
condition: any
environment:
<<: *mq-env
volumes:
- rabbitmq_data:/var/lib/rabbitmq
plane-minio:
image: minio/minio:latest
command: server /export --console-address ":9090"
deploy:
replicas: 1
restart_policy:
condition: any
environment:
<<: *minio-env
volumes:
- uploads:/export
proxy:
image: makeplane/plane-proxy:${APP_RELEASE:-stable}
deploy:
replicas: 1
restart_policy:
condition: any
environment:
<<: *proxy-env
ports:
- target: 80
published: 8090
protocol: tcp
mode: host
volumes:
- proxy_config:/config
- proxy_data:/data
depends_on:
- web
- api
- space
- admin
- live
volumes:
pgdata:
redisdata:
uploads:
logs_api:
logs_worker:
logs_beat-worker:
logs_migrator:
rabbitmq_data:
proxy_config:
proxy_data:
Why port 8090? Coolify's Traefik proxy already listens on ports 80 and 443. If Plane's internal proxy tried to bind to those same ports, the deployment would fail with a port conflict. We use 8090 as a free internal port, and Traefik handles the public-facing traffic on 80/443 and forwards it to Plane.
Step 4 — Configure the Environment Variables
In Coolify, go to your service's Environment Variables tab and add the following. Replace all values marked with YOUR_... with your actual values.
# ── Domain ────────────────────────────────────────────────
APP_DOMAIN=plane.yourdomain.com
APP_RELEASE=stable
WEB_URL=https://plane.yourdomain.com
CORS_ALLOWED_ORIGINS=https://plane.yourdomain.com
# ── Proxy ─────────────────────────────────────────────────
LISTEN_HTTP_PORT=8090
LISTEN_HTTPS_PORT=443
SITE_ADDRESS=:80
MINIO_ENDPOINT_SSL=0
# ── Security (generate your own random strings) ───────────
SECRET_KEY=YOUR_RANDOM_64_CHARACTER_STRING
LIVE_SERVER_SECRET_KEY=YOUR_RANDOM_32_CHARACTER_STRING
# ── Database ──────────────────────────────────────────────
PGHOST=plane-db
PGDATABASE=plane
POSTGRES_USER=plane
POSTGRES_PASSWORD=plane
POSTGRES_DB=plane
POSTGRES_PORT=5432
PGDATA=/var/lib/postgresql/data
DATABASE_URL=postgresql://plane:plane@plane-db/plane
# ── Redis ─────────────────────────────────────────────────
REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=redis://plane-redis:6379/
# ── RabbitMQ ──────────────────────────────────────────────
RABBITMQ_HOST=plane-mq
RABBITMQ_PORT=5672
RABBITMQ_USER=plane
RABBITMQ_PASSWORD=plane
RABBITMQ_VHOST=plane
AMQP_URL=amqp://plane:plane@plane-mq:5672/plane
# ── Storage ───────────────────────────────────────────────
USE_MINIO=1
AWS_ACCESS_KEY_ID=access-key
AWS_SECRET_ACCESS_KEY=secret-key
AWS_REGION=
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
# ── Other ─────────────────────────────────────────────────
DEBUG=0
API_BASE_URL=http://api:8000
API_KEY_RATE_LIMIT=60/minute
Generating secret keys: You need two secure random strings. Run this command on your server to generate each one:
openssl rand -hex 32
Run it twice — once for SECRET_KEY and once for LIVE_SERVER_SECRET_KEY. Copy each output and paste it as the value.
Important: Never share these keys publicly. They are used to sign sessions and authenticate internal services.
Step 5 — Deploy
Click Deploy in Coolify. The first deployment pulls all Docker images, which may take 3–5 minutes depending on your server's internet speed.
Once deployed, you'll see all services listed in the Logs tab. Don't worry if Coolify shows the service as "Degraded (unhealthy)" — this is expected. Coolify cannot health-check manually deployed Docker Compose stacks the same way it does its native resources. What matters is that the individual containers are running, which you can verify in the Logs tab.
Look for these indicators that everything is healthy:
-
Migrator — should show
No migrations to applyand then exit. This is normal — it's a one-time job. -
API — should show
Starting gunicornandListening at: http://0.0.0.0:8000 - Web, Admin, Space — should show health check requests every 30 seconds
-
Worker / Beat Worker — should show
Connected to amqp:// -
Live — should show
Express server has started at port 3000 -
Plane DB — should show
database system is ready to accept connections -
Plane Redis — should show
Ready to accept connections tcp
Step 6 — Configure Traefik to Route Your Domain to Plane
This is the most important step. Coolify uses Traefik as its reverse proxy — the service that receives all traffic on ports 80 and 443 and routes it to the right application. You need to tell Traefik to forward traffic for plane.yourdomain.com to Plane's internal proxy container.
6a — Find Plane's Docker Network
SSH into your server and run:
sudo docker network ls
You'll see output like:
NETWORK ID NAME DRIVER SCOPE
1ca43bdda6db bridge bridge local
7bebd2e6a992 sok0sgggc88kss0sos8k4w0s bridge local
The network with a random-looking name (not bridge, host, or none) is Plane's Docker network. Note the Network ID (e.g. 7bebd2e6a992).
To confirm it's Plane's network, run:
sudo docker inspect YOUR_NETWORK_ID | grep -i "name\|container"
You should see container names like proxy-..., api-..., web-..., etc. That confirms it's the right network.
Also note the exact name of Plane's proxy container from this output — it will look like proxy-sok0sgggc88kss0sos8k4w0s. You'll need this in the next step.
6b — Connect Traefik to Plane's Network
Traefik runs inside Docker and by default can only reach containers on its own network. Run this command to connect it to Plane's network (replace YOUR_NETWORK_ID with the ID you found above):
sudo docker network connect YOUR_NETWORK_ID coolify-proxy
6c — Create the Traefik Routing Rule
Now create a dynamic configuration file that tells Traefik how to route traffic for your domain. Replace plane.yourdomain.com with your actual domain, and proxy-sok0sgggc88kss0sos8k4w0s with your actual proxy container name from Step 6a.
sudo tee /data/coolify/proxy/dynamic/plane.yaml << 'EOF'
http:
routers:
plane-http:
rule: "Host(`plane.yourdomain.com`)"
entryPoints:
- http
middlewares:
- redirect-to-https
service: plane
plane-https:
rule: "Host(`plane.yourdomain.com`)"
entryPoints:
- https
service: plane
tls:
certResolver: letsencrypt
middlewares:
redirect-to-https:
redirectScheme:
scheme: https
permanent: true
services:
plane:
loadBalancer:
servers:
- url: "http://proxy-sok0sgggc88kss0sos8k4w0s:80"
EOF
What this does: It creates two Traefik routers — one that catches HTTP traffic and redirects it to HTTPS, and one that handles HTTPS traffic with an automatically issued Let's Encrypt SSL certificate, forwarding everything to Plane's internal proxy container by name.
6d — Set Correct File Permissions and Reload Traefik
Traefik runs as a non-root user (UID 9999). The config file needs to be owned by that user, otherwise Traefik silently ignores it:
sudo chown 9999:9999 /data/coolify/proxy/dynamic/plane.yaml
sudo docker kill --signal=SIGHUP coolify-proxy
The SIGHUP signal tells Traefik to reload its configuration without restarting.
Step 7 — Verify It's Working
Open your browser and go to https://plane.yourdomain.com.
You should see the Plane sign-up screen. Create your admin account and your first workspace.
If you see a Bad Gateway error, wait 30–60 seconds and refresh. Traefik may still be issuing the SSL certificate from Let's Encrypt. If the error persists, check the troubleshooting section below.
Step 8 — Access the Instance Admin Panel
Plane has a separate admin panel called God Mode for managing instance-level settings like authentication methods, email configuration, and sign-up restrictions.
Access it at:
https://plane.yourdomain.com/god-mode/
Log in with the same credentials you used to create your first account. From here you can:
- Enable or disable public sign-ups
- Configure SMTP for email notifications
- Set up OAuth (Google, GitHub)
- View instance health and telemetry settings
Troubleshooting
Bad Gateway after setup
This usually means Traefik can't reach Plane's proxy container. The most common causes are:
- The
plane.yamlfile has wrong permissions — re-run thechowncommand from Step 6d. - Traefik isn't connected to Plane's Docker network — re-run the
docker network connectcommand from Step 6b. - The container name in
plane.yamlis wrong — double-check it against the output ofsudo docker inspect YOUR_NETWORK_ID | grep Name.
Migrator container keeps restarting
This is normal on the first run. The migrator is a one-time job — once it finishes applying database migrations, it exits. Coolify may show it as "Exited" which is the correct final state.
Coolify shows "Degraded (unhealthy)"
This is expected and can be ignored. Coolify can't perform health checks on manually managed Docker Compose stacks. As long as your containers are running and the site is accessible, everything is fine.
SSL certificate not issuing
Make sure your domain's A record is pointing to the correct server IP and has fully propagated. You can check propagation at dnschecker.org. Traefik will automatically retry certificate issuance — give it a few minutes.
Port 8090 already in use
Run sudo ss -tlnp | grep 8090 to check. If it's taken, choose another free port (e.g. 8091), update LISTEN_HTTP_PORT in your environment variables, redeploy, and update the published port in the compose file's proxy service accordingly.
Summary
You now have a fully self-hosted Plane instance running on your own infrastructure at zero licensing cost. Here's what you set up:
- A full Plane Community Edition stack with PostgreSQL, Redis, RabbitMQ, and MinIO storage
- Coolify managing the Docker Compose deployment
- Traefik handling HTTPS with automatic SSL from Let's Encrypt
- A clean routing setup that doesn't interfere with other apps on the same server
The Community Edition includes unlimited projects, unlimited users, kanban boards, timelines, cycles, modules, dashboards, pages, and a full REST API. For most small to medium teams migrating off Monday.com, it covers everything you need.
This guide was written by Batin Studio — a digital product design and development agency. If you'd like help setting this up for your team or need a custom deployment, get in touch.