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:
| Service | What it is | Published on |
|---|---|---|
app | The Plotra web app (Next.js) | port 3000 |
collab | The live-editing server browsers hold a WebSocket to | port 8787 |
postgres | The database (Postgres 18) | not published |
minio | S3-compatible storage for uploads and version snapshots | not published |
migrate, minio-init | One-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.
gitandopenssl.- 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 .envGenerate 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.bakStart it:
docker compose up -d --buildThe 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/healthThe 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
| Variable | Default | What it does |
|---|---|---|
BETTER_AUTH_URL | http://localhost:3000 | The 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_URL | http://localhost:8787 | The URL browsers reach the collab server at. Build |
APP_PORT, COLLAB_PORT | 3000, 8787 | The host ports the two are published on |
APP_BIND, COLLAB_BIND | 0.0.0.0 | The 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
| Variable | What it does |
|---|---|
BETTER_AUTH_SECRET | Signs sessions and tokens. Changing it signs everyone out |
COLLAB_SECRET | Shared by the app and the collab server, so each knows the other is calling |
POSTGRES_PASSWORD | Password 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_KEY | Password of the bundled file store, and what the app signs its requests with |
AI_KEY_SECRET | Encrypts the AI provider keys people save in Settings. Changing it makes saved keys unreadable |
What runs
| Variable | Default | What it does |
|---|---|---|
COMPOSE_PROFILES | postgres,minio,collab | The 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_URL | empty | Empty means the bundled Postgres. Otherwise a Postgres connection string |
DATABASE_DRIVER | empty | pg 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.
| Variable | Default | What it does |
|---|---|---|
S3_ENDPOINT | http://minio:9000 | The store's URL. The bucket is addressed as <endpoint>/<bucket>/<key> |
S3_BUCKET | plotra | The bucket. minio-init creates it in the bundled store |
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | plotra, generated | Credentials. The bundled MinIO takes them as its root user and password |
S3_REGION | empty | The 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_BUCKET | empty | Cloudflare 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
| Variable | Default here | What it does |
|---|---|---|
QUOTA_PROJECTS | 0 | Projects per workspace. 0 means no limit |
QUOTA_STORAGE_MB | 0 | Storage per workspace, in MB |
QUOTA_FILE_MB | 0 | One 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.
| Variable | What it does |
|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Optional. The Google button appears when both are set. Register <BETTER_AUTH_URL>/api/auth/callback/google as the callback URL |
RESEND_API_KEY | Optional. Sends invitations and sharing notices through Resend |
EMAIL_FROM | The 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:
| Variable | What it does |
|---|---|
AI_PROVIDER | anthropic, openai, openrouter, compatible (any OpenAI-compatible endpoint) or ollama |
AI_API_KEY | The key, for providers that need one |
AI_BASE_URL | For compatible and ollama. An Ollama on the Docker host is http://host.docker.internal:11434/v1 |
AI_MODEL_AGENT, AI_MODEL_FAST | The models to use. The fast one defaults to the agent one |
AI_ALLOW_PRIVATE_URLS | true 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
| Variable | What it does |
|---|---|
SENTRY_DSN | Optional error tracking. Build as well as run time |
NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_DEMO_LINK, NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST | Optional, described in apps/app/.env.example. Build |
MINIO_IMAGE | The 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 appEverything 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.1Then 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
Hostheader through and setX-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_URLat the second one, such ashttps://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:
| What | Holds | If it is lost |
|---|---|---|
plotra_postgres-data | Accounts, workspaces, projects, every note's text, comments, links | Everything is gone |
plotra_minio-data | Uploaded files and version snapshots | Notes survive. Attachments and older versions don't |
plotra_collab-data | The live state of every note that has been opened | Little: the collab server copies each note to Postgres within 10 seconds of an edit, and reloads from there |
docker/.env | The secrets | Sessions 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).dumpThe 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 minioThe 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 -dWhen 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 --buildThat 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 migrateThere 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
collabservice 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/datareloads from there. - It contacts the internet on start to look for a newer
wranglerand to fetch request metadata from Cloudflare. Both fail quietly without a connection, and usage reporting is switched off. wrangler devhas a debugging interface that can read the stored notes. The container switches it off (X_LOCAL_EXPLORER=falseindocker/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 see | Likely cause |
|---|---|
required variable BETTER_AUTH_SECRET is missing a value | No docker/.env, or the secrets were not generated |
migrate exits with an error about permissions or roles | The 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 … 401 | COLLAB_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 stick | BETTER_AUTH_URL is not the address in the browser: https against http, another host, or another port |
| Creating a note does nothing | Plain HTTP on an address other than localhost. See the note under Quick start |
minio keeps restarting | S3_SECRET_ACCESS_KEY is empty or shorter than 8 characters |
To start over and delete all data: docker compose down -v.