Plotra Docs
Self-hosting

Self-hosting with Docker

Run your own Plotra with one docker compose command, the app, Postgres, the collab server and file storage included.

Plotra is open source (AGPL-3.0) and everything in it runs on your own machine. One compose file starts the whole thing:

ServiceWhat it isPublished on
appThe Plotra web app (Next.js)port 3000
collabThe live-editing server browsers hold a WebSocket toport 8787
postgresThe database (Postgres 18)not published
minioS3-compatible storage for uploads and version snapshotsnot published
migrate, minio-initOne-shot jobs: apply database migrations, create the bucket

A self-hosted Plotra has no limits on projects or storage, needs no account anywhere, and sends no email unless you set that up.

What has been tested

Every part of this setup was run outside Docker on the machine it was written on: the standalone build of the app against a plain Postgres 18, the migrations, sign-up, a workspace, a project, file upload through an S3 endpoint, and live editing through the collab server with its state surviving a restart. The compose file was checked with docker compose config. The images themselves had not been built when this page was written, because Docker wasn't running there. If a build or a start fails for you, please open an issue with the output. When someone has run it end to end, replace this note with the date.

Requirements

  • Docker with Compose v2.20 or newer (docker compose version).
  • About 2 GB of free memory to build the app image, and 1 GB to run the stack.
  • git and openssl.
  • For anything other than http://localhost: a domain name and a reverse proxy that does TLS. See Reverse proxy and TLS.

Quick start

git clone https://github.com/ItzSudhan/plotra.git
cd plotra/docker
cp .env.example .env

Generate the secrets. This fills every empty secret in .env, and leaves filled ones alone if you run it again:

for name in POSTGRES_PASSWORD BETTER_AUTH_SECRET COLLAB_SECRET S3_SECRET_ACCESS_KEY AI_KEY_SECRET; do
  sed -i.bak "s|^$name=\$|$name=$(openssl rand -hex 32)|" .env
done && rm -f .env.bak

Start it:

docker compose up -d --build

The first run builds three images and takes a few minutes. When docker compose ps shows app and collab as healthy, open http://localhost:3000 and sign up with an email and a password. The first account is an ordinary account: there is no admin user to set up.

To check the instance from the command line:

curl http://localhost:3000/api/health
curl http://localhost:8787/health

The first answers {"ok":true,"database":"ok",…} and lists which optional services are configured. The second is the collab server, and answers {"ok":true}.

Use localhost or HTTPS

Over plain HTTP on any address other than localhost, such as http://192.168.1.20:3000, browsers switch off features Plotra relies on. Creating notes and the copy buttons stop working. To use Plotra from other devices, put it behind HTTPS.

Settings

Everything is set in docker/.env. After changing a value, run docker compose up -d again. The two settings marked "build" are compiled into the app, so they need docker compose up -d --build app.

Addresses

VariableDefaultWhat it does
BETTER_AUTH_URLhttp://localhost:3000The URL people open Plotra at, without a trailing slash. Sign-in cookies, links in emails and the collab server's token check use it, so it must match the browser's address bar exactly
NEXT_PUBLIC_COLLAB_URLhttp://localhost:8787The URL browsers reach the collab server at. Build
APP_PORT, COLLAB_PORT3000, 8787The host ports the two are published on
APP_BIND, COLLAB_BIND0.0.0.0The host address they bind to. Use 127.0.0.1 when a reverse proxy on the same machine is the only thing that should reach them

If you change APP_PORT without a reverse proxy, change the port in BETTER_AUTH_URL to match. The same goes for COLLAB_PORT and NEXT_PUBLIC_COLLAB_URL.

Secrets

VariableWhat it does
BETTER_AUTH_SECRETSigns sessions and tokens. Changing it signs everyone out
COLLAB_SECRETShared by the app and the collab server, so each knows the other is calling
POSTGRES_PASSWORDPassword of the bundled Postgres. It is set when the database volume is first created. Changing it in .env later does not change it in the database
S3_SECRET_ACCESS_KEYPassword of the bundled file store, and what the app signs its requests with
AI_KEY_SECRETEncrypts the AI provider keys people save in Settings. Changing it makes saved keys unreadable

What runs

VariableDefaultWhat it does
COMPOSE_PROFILESpostgres,minio,collabThe optional services. Remove postgres to use your own database, minio to use another file store or none, collab to run the collab worker on Cloudflare
DATABASE_URLemptyEmpty means the bundled Postgres. Otherwise a Postgres connection string
DATABASE_DRIVERemptypg or neon. Empty picks neon for a neon.tech host and pg for everything else

Files

Uploads and version snapshots go to an S3-compatible store. The bundled one is MinIO; the defaults in .env point at it.

VariableDefaultWhat it does
S3_ENDPOINThttp://minio:9000The store's URL. The bucket is addressed as <endpoint>/<bucket>/<key>
S3_BUCKETplotraThe bucket. minio-init creates it in the bundled store
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEYplotra, generatedCredentials. The bundled MinIO takes them as its root user and password
S3_REGIONemptyThe region requests are signed for. Empty means us-east-1, MinIO's default
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKETemptyCloudflare R2 instead. Leave S3_ENDPOINT empty to use these

Nothing outside the stack talks to the store: the app reads and writes files on behalf of browsers. That is why MinIO has no published port.

To use another S3 store (Garage, SeaweedFS, AWS S3), set the five S3_ values and remove minio from COMPOSE_PROFILES. Create the bucket yourself.

To run with no store at all, also empty S3_ENDPOINT. Files then go into Postgres, with smaller limits: 10 MB a file and 100 MB a project.

About the MinIO image

MinIO stopped publishing its own Docker image, so the compose file uses pgsty/minio, a community-maintained build of the same server, pinned to one release. Set MINIO_IMAGE in .env to use a different build. Any S3-compatible store works in its place.

Limits

VariableDefault hereWhat it does
QUOTA_PROJECTS0Projects per workspace. 0 means no limit
QUOTA_STORAGE_MB0Storage per workspace, in MB
QUOTA_FILE_MB0One uploaded file, in MB. With no limit set, a single file can still be at most 100 MB, and one project's attachments 2 GB together

The hosted version sets these so it can stay free. See Limits on the hosted version.

Sign-in and email

Email and password sign-in works with nothing else set up. Anyone who can reach your instance can sign up. They get their own workspace and see nothing of yours, but if you don't want strangers on it, don't expose it to the internet, or put your reverse proxy's access control in front of it.

VariableWhat it does
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETOptional. The Google button appears when both are set. Register <BETTER_AUTH_URL>/api/auth/callback/google as the callback URL
RESEND_API_KEYOptional. Sends invitations and sharing notices through Resend
EMAIL_FROMThe sender, on a domain you verified in Resend, such as Plotra <plotra@example.com>

Without RESEND_API_KEY no email is sent. Each invitation or sharing link is written to the app's log instead, and you pass it on yourself:

docker compose logs app | grep '\[plotra\]'
[plotra] Invitation for grace@example.com to Test Press: http://localhost:3000/invite/WNHMQN8S…

AI

The AI agent never runs on a key of the project's. Each person adds their own provider key in Settings, which needs AI_KEY_SECRET. Or you give the whole instance one model:

VariableWhat it does
AI_PROVIDERanthropic, openai, openrouter, compatible (any OpenAI-compatible endpoint) or ollama
AI_API_KEYThe key, for providers that need one
AI_BASE_URLFor compatible and ollama. An Ollama on the Docker host is http://host.docker.internal:11434/v1
AI_MODEL_AGENT, AI_MODEL_FASTThe models to use. The fast one defaults to the agent one
AI_ALLOW_PRIVATE_URLStrue here, so a model URL may point at this machine or your network, which a local model needs. Set it to false if people you don't know can sign up: otherwise they can make your server send requests to addresses inside your network

Other

VariableWhat it does
SENTRY_DSNOptional error tracking. Build as well as run time
NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_DEMO_LINK, NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOSTOptional, described in apps/app/.env.example. Build
MINIO_IMAGEThe image of the bundled file store

Why some settings need a rebuild

Next.js compiles every NEXT_PUBLIC_ value into the JavaScript it sends to browsers. They are fixed when the image is built, not when the container starts. The compose file passes them from .env to the build as build arguments, so the image you build carries your values:

docker compose up -d --build app

Everything else is read when the container starts.

Reverse proxy and TLS

Browsers need to reach two things: the app and the collab server's WebSocket. Neither container does TLS, so put a reverse proxy in front.

Browsers only ever talk to the collab server under /parties/, and the app has no pages there. So one domain is enough: send /parties/* to the collab server and everything else to the app.

In .env:

BETTER_AUTH_URL=https://plotra.example.com
NEXT_PUBLIC_COLLAB_URL=https://plotra.example.com
APP_BIND=127.0.0.1
COLLAB_BIND=127.0.0.1

Then rebuild, because NEXT_PUBLIC_COLLAB_URL changed: docker compose up -d --build.

With Caddy, which gets certificates by itself and handles WebSockets without extra settings:

plotra.example.com {
    handle /parties/* {
        reverse_proxy 127.0.0.1:8787
    }
    handle {
        reverse_proxy 127.0.0.1:3000
    }
}

With nginx, inside a server block that already has your certificate:

location /parties/ {
    proxy_pass http://127.0.0.1:8787;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 1h;
}

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    client_max_body_size 110m;
}

Things that matter whichever proxy you use:

  • Pass the Host header through and set X-Forwarded-Proto. The app builds its redirects from them.
  • Set X-Forwarded-For. Rate limits count by client address. Without it everyone shares one limit.
  • Allow WebSocket upgrades and long-lived connections on /parties/.
  • Allow large request bodies on the app if you want large uploads.
  • Send only /parties/ to the collab server. Browsers need nothing else from it.
  • Two domains work as well: point NEXT_PUBLIC_COLLAB_URL at the second one, such as https://collab.plotra.example.com.

The collab container reaches the app directly inside the compose network (http://app:3000), not through your proxy. It still needs BETTER_AUTH_URL to be the public address, because the tokens it checks name that address as their issuer. The compose file passes both.

Backups

Three volumes hold data, plus your .env:

WhatHoldsIf it is lost
plotra_postgres-dataAccounts, workspaces, projects, every note's text, comments, linksEverything is gone
plotra_minio-dataUploaded files and version snapshotsNotes survive. Attachments and older versions don't
plotra_collab-dataThe live state of every note that has been openedLittle: the collab server copies each note to Postgres within 10 seconds of an edit, and reloads from there
docker/.envThe secretsSessions end, and saved AI keys can't be decrypted

The database. pg_dump works while the stack is running:

docker compose exec -T postgres pg_dump -U plotra -Fc plotra > plotra-$(date +%F).dump

The file store. Archive the volume. Stop the store first for a consistent copy:

docker compose stop minio
docker run --rm -v plotra_minio-data:/data:ro -v "$PWD":/backup alpine \
  tar czf /backup/minio-$(date +%F).tar.gz -C /data .
docker compose start minio

The same command with plotra_collab-data copies the collab server's state; stop collab for it.

Restoring the database into a new, empty instance. pg_dump does not include roles, so create the one Plotra's row-level security uses before loading the dump:

docker compose up -d postgres
docker compose exec -T postgres psql -U plotra -d plotra \
  -c "create role plotra_app nologin nobypassrls; grant plotra_app to plotra;"
docker compose exec -T postgres pg_restore -U plotra -d plotra --no-owner < plotra-2026-10-02.dump
docker compose up -d

When you restore an older database into an instance that has been running, remove the collab volume too (docker volume rm plotra_collab-data, with the stack down). Otherwise the collab server still holds the newer text of each note and writes it back over the restored one.

Rehearse it

The restore steps above were written without being run. Do one restore into a scratch instance before you rely on your backups.

Upgrading

Back up the database first. Then:

git pull
docker compose up -d --build

That rebuilds the images, runs the migrate job, and only then starts the new app: the app waits for migrate to finish, and does not start if a migration fails. Migrations that were already applied are skipped. To see what it did:

docker compose logs migrate

There are no tagged releases yet, so git pull takes the current master. Read the commit log for anything that mentions self-hosting before you upgrade.

Using Neon or another Postgres

The bundled Postgres is optional. To run against Neon instead, create a project there, copy its connection string, and in .env set DATABASE_URL to it and remove postgres from COMPOSE_PROFILES (leaving COMPOSE_PROFILES=minio,collab). Then docker compose up -d --build: the migrate job creates the tables in Neon, and the app connects with Neon's serverless driver, which it picks by itself for a neon.tech host. The pooled and the direct connection string both work. Set DATABASE_DRIVER=pg if you would rather connect over plain TCP, as the migrations do.

The same goes for any other Postgres: set DATABASE_URL, drop the postgres profile. Plotra is developed and tested on Postgres 18; older versions have not been tried. One requirement holds everywhere. The role in the connection string must own the database and be allowed to create roles, because a migration creates plotra_app, the role without privileges that every user request runs as so that Postgres row-level security applies to it. Neon's default role qualifies. On your own server, a superuser does, or a role with CREATEROLE that owns the database.

The self-hosted collab server and its limits

On the hosted version the collab server is a Cloudflare Worker with one Durable Object per open note. The collab container runs that same worker, unchanged, under workerd, Cloudflare's open-source runtime, started through wrangler dev. Its storage is one SQLite file per note under /data, on the plotra_collab-data volume.

That keeps one codebase for both, and it has limits you should know:

  • It is Cloudflare's local development mode. It is the same runtime, but Cloudflare does not offer it as a production server. It has been run here for editing by a few people at once, not under load.
  • One process, one machine. It cannot be scaled out by running more copies: two copies would each hold their own version of a note.
  • Every open note is held in memory until everyone has left it.
  • It builds the worker each time the container starts, which takes a few seconds. The collab service waits for the app to be healthy first.
  • It does no TLS. Browsers on an HTTPS page can only connect to it through a proxy that does.
  • Its files are in Miniflare's format, which could change with a new major version of wrangler. The version is pinned by the repo's lockfile. Postgres has a copy of every note, so an emptied /data reloads from there.
  • It contacts the internet on start to look for a newer wrangler and to fetch request metadata from Cloudflare. Both fail quietly without a connection, and usage reporting is switched off.
  • wrangler dev has a debugging interface that can read the stored notes. The container switches it off (X_LOCAL_EXPLORER=false in docker/collab-entrypoint.sh). Keep that setting if you write your own entrypoint, and proxy only /parties/ as shown above.

If these limits matter to you, you can deploy apps/collab to your own Cloudflare account instead (the free plan covers it) and point NEXT_PUBLIC_COLLAB_URL at it. Set its APP_URL to your BETTER_AUTH_URL and its COLLAB_SECRET to yours, and remove collab from COMPOSE_PROFILES. Your app then has to be reachable from the internet, because the worker calls it.

Troubleshooting

What you seeLikely cause
required variable BETTER_AUTH_SECRET is missing a valueNo docker/.env, or the secrets were not generated
migrate exits with an error about permissions or rolesThe database role may not create roles. See Using Neon or another Postgres
The editor stays on "Connecting…"The browser cannot reach NEXT_PUBLIC_COLLAB_URL, or it changed and the app image was not rebuilt, or BETTER_AUTH_URL is not the address in the browser
collab logs Couldn't seed … 401COLLAB_SECRET differs between the app and the collab server. Both read it from .env: run docker compose up -d
Signing in fails, or doesn't stickBETTER_AUTH_URL is not the address in the browser: https against http, another host, or another port
Creating a note does nothingPlain HTTP on an address other than localhost. See the note under Quick start
minio keeps restartingS3_SECRET_ACCESS_KEY is empty or shorter than 8 characters

To start over and delete all data: docker compose down -v.

On this page