Skip to content

Docker (self-host)

Copy page

The server image (Dockerfile.server) runs the full Plan Desk server on a host you control — the self-host topology. You bring the database; there is no dependency on asyncdot infrastructure and no GitHub app required.

Terminal window
export PLANDESK_AUTH_PASSWORD='choose-a-strong-password'
docker compose -f docker-compose.hosted.yml up --build

Open http://127.0.0.1:7526.

By default this uses a local SQLite file on a Docker volume (migrated automatically at boot). For a durable database, point it at your own libSQL/Turso database and apply the schema once:

Terminal window
export PLANDESK_DB_URL='libsql://your-db.turso.io'
export PLANDESK_DB_TOKEN='<libSQL auth token>'
# Apply the schema once (you own this database — REQ-8)
docker compose -f docker-compose.hosted.yml run --rm plandesk \
migrate --db "$PLANDESK_DB_URL" --db-token "$PLANDESK_DB_TOKEN"
docker compose -f docker-compose.hosted.yml up --build
Terminal window
docker build -f Dockerfile.server -t plandesk-server .
docker run -p 7526:7526 \
-e PLANDESK_DB_URL='libsql://your-db.turso.io' \
-e PLANDESK_DB_TOKEN='<token>' \
-e PLANDESK_AUTH_PASSWORD='<password>' \
plandesk-server

Everything the server needs can be set by environment or by a plandesk.server.json file mounted at /data/plandesk.server.json (env always wins). Inspect the resolved config and its source with plandesk doctor — secret values are redacted:

Terminal window
docker compose -f docker-compose.hosted.yml run --rm plandesk doctor

Plan Desk treats a loopback bind127.0.0.1, ::1 or localhost — as proof that only this machine can reach it. On a loopback bind every request is the org owner, with no login at all. That is what makes a local board zero-setup: you run plandesk serve, open the browser, and it works.

On any other bind address that trust is gone and better-auth does the authenticating.

So do not bind loopback and put a reverse proxy in front of it. That shape is normally good practice — keep the app port off the network, terminate TLS at nginx or Caddy — but here it defeats the model: the server still believes only this machine can reach it, while the proxy hands the internet an owner session. There is no error and nothing in the UI looks wrong; the board simply has no access control.

Two safe shapes:

BindWho can reach itAuthentication
Local board127.0.0.1this machine only, no proxynone needed — loopback is the boundary
Served board0.0.0.0proxy or networkbetter-auth, and PLANDESK_AUTH_PASSWORD

The compose file already does the right thing: PLANDESK_HOST defaults to 0.0.0.0, and the container’s network isolation — not a loopback bind — is what keeps the port private.

  • PLANDESK_AUTH_PASSWORD enables HTTP basic-auth on the UI and REST API. Set it for any host reachable beyond your own machine. Without it the server is open — fine on a trusted LAN, not for a public host.
  • TLS — front the container with nginx/Caddy for HTTPS. The server binds 0.0.0.0 inside the container; do the TLS termination at your reverse proxy.
  • GitHub sign-in is optional — omit the GitHub env/keys and the server runs with token auth only (REQ-20).
  • Local file topology (no PLANDESK_DB_URL): state lives in the plandesk-data volume (/data in the container). Back up the volume.
  • Remote DB topology (PLANDESK_DB_URL set): state lives in your database. Back that up. The volume then only holds plandesk.server.json.
VariableDefaultPurpose
PLANDESK_DB_URL(unset → local file)libSQL/Turso URL for the server’s database
PLANDESK_DB_TOKEN(unset)Auth token for a remote libSQL DB (secret)
PLANDESK_HOST0.0.0.0Bind address
PLANDESK_PORT7526Bind port
PLANDESK_AUTH_PASSWORD(unset)HTTP basic-auth password (secret)
PLANDESK_STORAGElocallocal (blobs in DB) or s3
PLANDESK_S3_*(unset)S3 credentials when PLANDESK_STORAGE=s3
PLANDESK_GITHUB_CLIENT_ID / _SECRET / _CALLBACK_URL(unset)GitHub OAuth (all-or-nothing; omit for no GitHub sign-in)