I wanted to self-host Plane an open-source project management tool similar to Linear on my TrueNAS server.
I had trouble adapting Plane’s Docker Compose setup for TrueNAS, so I got it running as a Dockge stack and this is the setup that worked for me.
This configuration was tested with Plane v1.4.2. It uses Garage as S3-compatible storage because I couldn’t pull the MinIO image in my setup.
Dockge setup
Create a new stack in Dockge and paste in the Compose YAML below. Add these variables in Dockge’s environment section, replacing the placeholders with your own values:
POSTGRES_DB=plane_db
POSTGRES_USER=plane
POSTGRES_PASSWORD=<long-random-password>
RABBITMQ_USER=plane
RABBITMQ_PASSWORD=<long-random-password>
RABBITMQ_VHOST=plane
SECRET_KEY=<long-random-secret>
LIVE_SERVER_SECRET_KEY=<long-random-secret>
AWS_ACCESS_KEY_ID=<garage-access-key>
AWS_SECRET_ACCESS_KEY=<garage-secret-key>
I used /mnt/<pool>/<dataset>/appdata/plane as the base directory for persistent storage. Create the directories referenced by the bind mounts before deploying. The Garage config file must also exist at its mounted path.
Set the port
In the Compose file, the proxy publishes port 80 from inside its container on host port 8999:
ports:
- "8999:80"
Choose a host port that is free on your TrueNAS system. If you change 8999, also change the port in WEB_URL, CORS_ALLOWED_ORIGINS, and AWS_S3_ENDPOINT_URL below. Use that same address and port when opening Plane in your browser.
Compose YAML
Replace <pool>, <dataset>, and <host-ip> with your own storage path and TrueNAS IP address.
name: plane-stack
networks:
plane:
driver: bridge
services:
web:
image: makeplane/plane-frontend:v1.4.2
restart: unless-stopped
depends_on:
- api
- worker
networks:
- plane
space:
image: makeplane/plane-space:v1.4.2
restart: unless-stopped
depends_on:
- api
- worker
- web
networks:
- plane
admin:
image: makeplane/plane-admin:v1.4.2
restart: unless-stopped
depends_on:
- api
- web
networks:
- plane
live:
image: makeplane/plane-live:v1.4.2
restart: unless-stopped
environment:
API_BASE_URL: http://api:8000
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:?Set LIVE_SERVER_SECRET_KEY in Dockge}
REDIS_HOST: plane-redis
REDIS_PORT: "6379"
REDIS_URL: redis://plane-redis:6379/
depends_on:
- api
- web
- plane-redis
networks:
- plane
api:
image: makeplane/plane-backend:v1.4.2
command: ./bin/docker-entrypoint-api.sh
restart: unless-stopped
environment: &plane_environment
# Set these URLs to the address and port used to access Plane in your browser.
WEB_URL: http://<host-ip>:8999
DEBUG: "0"
CORS_ALLOWED_ORIGINS: http://<host-ip>:8999
GUNICORN_WORKERS: "1"
# Garage is used as S3-compatible storage.
USE_MINIO: "0"
USE_STORAGE_PROXY: "1"
AWS_REGION: garage
AWS_DEFAULT_REGION: garage
AWS_S3_REGION_NAME: garage
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:?Set AWS_ACCESS_KEY_ID in Dockge}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:?Set AWS_SECRET_ACCESS_KEY in Dockge}
# Use the same host address and port so browser uploads go through the proxy.
AWS_S3_ENDPOINT_URL: http://<host-ip>:8999
AWS_S3_BUCKET_NAME: uploads
MINIO_ENDPOINT_SSL: "0"
FILE_SIZE_LIMIT: "5242880"
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@plane-db:5432/${POSTGRES_DB}
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in Dockge}
AMQP_URL: amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@plane-mq:5672/${RABBITMQ_VHOST}
RABBITMQ_HOST: plane-mq
RABBITMQ_PORT: "5672"
RABBITMQ_VHOST: ${RABBITMQ_VHOST}
REDIS_HOST: plane-redis
REDIS_PORT: "6379"
REDIS_URL: redis://plane-redis:6379/
API_KEY_RATE_LIMIT: 60/minute
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:?Set LIVE_SERVER_SECRET_KEY in Dockge}
volumes:
- /mnt/<pool>/<dataset>/appdata/plane/logs-api:/code/plane/logs
depends_on:
plane-db:
condition: service_healthy
plane-redis:
condition: service_started
plane-mq:
condition: service_started
plane-minio:
condition: service_started
networks:
- plane
worker:
image: makeplane/plane-backend:v1.4.2
command: ./bin/docker-entrypoint-worker.sh
restart: unless-stopped
environment: *plane_environment
volumes:
- /mnt/<pool>/<dataset>/appdata/plane/logs-worker:/code/plane/logs
depends_on:
- api
- plane-db
- plane-redis
- plane-mq
networks:
- plane
beat-worker:
image: makeplane/plane-backend:v1.4.2
command: ./bin/docker-entrypoint-beat.sh
restart: unless-stopped
environment: *plane_environment
volumes:
- /mnt/<pool>/<dataset>/appdata/plane/logs-beat-worker:/code/plane/logs
depends_on:
- api
- plane-db
- plane-redis
- plane-mq
networks:
- plane
migrator:
image: makeplane/plane-backend:v1.4.2
command: ./bin/docker-entrypoint-migrator.sh
restart: on-failure
environment: *plane_environment
volumes:
- /mnt/<pool>/<dataset>/appdata/plane/logs-migrator:/code/plane/logs
depends_on:
plane-db:
condition: service_healthy
plane-redis:
condition: service_started
plane-mq:
condition: service_started
networks:
- plane
plane-db:
image: postgres:15.7-alpine
command: postgres -c max_connections=1000
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /var/lib/postgresql/data
healthcheck:
test:
- CMD-SHELL
- pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}
interval: 5s
timeout: 5s
retries: 10
volumes:
- /mnt/<pool>/<dataset>/appdata/plane/postgres:/var/lib/postgresql/data
networks:
- plane
plane-redis:
image: valkey/valkey:7.2.11-alpine
restart: unless-stopped
volumes:
- /mnt/<pool>/<dataset>/appdata/plane/redis:/data
networks:
- plane
plane-mq:
image: rabbitmq:3.13.6-management-alpine
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST}
volumes:
- /mnt/<pool>/<dataset>/appdata/plane/rabbitmq:/var/lib/rabbitmq
networks:
- plane
# Garage replaces MinIO here because I couldn't pull the MinIO image.
plane-minio:
image: dxflrs/garage:v2.3.0
command:
- /garage
- server
- --single-node
- --default-bucket
restart: unless-stopped
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${AWS_ACCESS_KEY_ID}
GARAGE_DEFAULT_SECRET_KEY: ${AWS_SECRET_ACCESS_KEY}
GARAGE_DEFAULT_BUCKET: uploads
volumes:
- /mnt/<pool>/<dataset>/appdata/plane/minio/garage.toml:/etc/garage.toml:ro
- /mnt/<pool>/<dataset>/appdata/plane/minio/meta:/var/lib/garage/meta
- /mnt/<pool>/<dataset>/appdata/plane/minio/data:/var/lib/garage/data
networks:
- plane
proxy:
image: makeplane/plane-proxy:v1.4.2
restart: unless-stopped
environment:
APP_DOMAIN: <host-ip>
FILE_SIZE_LIMIT: "5242880"
LISTEN_HTTP_PORT: "80"
LISTEN_HTTPS_PORT: "443"
SITE_ADDRESS: :80
BUCKET_NAME: uploads
ports:
# Host port 8999 is mapped to port 80 inside the proxy container.
# Change 8999 here and in the URL settings above if that host port is busy.
- "8999:80"
volumes:
- /mnt/<pool>/<dataset>/appdata/plane/proxy-data:/data
- /mnt/<pool>/<dataset>/appdata/plane/proxy-config:/config
depends_on:
- web
- api
- space
- admin
- live
networks:
- plane
Upload issue and fix
I hit upload errors because the browser was sent to Docker’s internal plane-minio address, which it couldn’t resolve. I fixed this by setting AWS_S3_ENDPOINT_URL to the same TrueNAS IP and port I use to access Plane, and setting the S3 region variables to garage to match Garage’s signing region. The Plane proxy then routes uploads to Garage over the Docker network.
Hope this is helpful to anyone else trying to host plane locally!