Basalt docs
GitHub

Self-hosting Basalt

Basalt runs as two containers: the app (API, collaboration server, jobs — one process) and Postgres 16. The image you run is the same image the managed cloud runs (ADR-0008); nothing is held back for a paid tier.

This page gets you to a running instance. Operations covers what happens afterwards — backups, upgrades, putting it on the internet. Configuration is the complete list of settings.

Two routes, and which one you want

There are published images now, so there are two ways to run Basalt. They are the same software — ADR-0008 says one image serves self-host and cloud, differing only by configuration — and the difference is only where the bytes come from.

From an image From source
Compose file infra/docker/compose.services.yml infra/docker/compose.selfhost.yml
First start ~1 minute (a pull) 5–10 minutes (a build)
Needs Docker, and access to ghcr.io/speedxy/* Docker, ~2 GB RAM, ~5 GB disk
Also gives you Lithic on a second domain nothing extra
Upgrade docker compose pull git pull and rebuild

Take the image route unless you intend to modify the code. The one catch is access: the packages are private, and deliberately so — the runtime image carries packages/* as TypeScript source, so making them public would be a licensing decision rather than a registry setting. docker login ghcr.io with a token that can read them first.

The rest of this page uses the source route, because it works for everyone with no credentials at all. Everything after "First run" applies to both; Operations → Two domains is the image route end to end.

Before you start

  • Docker with the Compose plugin (Docker 24+, Compose v2).
  • ~2 GB of free RAM and ~5 GB of disk. The build is the hungry part: it installs the whole workspace and compiles both apps.
  • 5–10 minutes on a laptop for that first build.

You do not need Node, pnpm or Postgres on the host. Everything runs in containers.

Install

git clone https://github.com/speedxy/basalt.git
cd basalt

# 1. Create the env file with strong secrets.
cat > infra/docker/.env <<EOF
AUTH_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 16)
EOF

# 2. Build and start (app + Postgres). --wait blocks until both are healthy.
docker compose -f infra/docker/compose.selfhost.yml up -d --build --wait

# 3. Check it answers, then open it.
curl http://localhost:8080/api/v1/health   # -> {"status":"ok"}

Then open http://localhost:8080.

Database migrations run automatically when the server boots — advisory-locked and forward-only, so there is no separate migration step and never will be.

First run

  1. Register. The registration form creates an account. Nothing else happens: there is no first-user ceremony and no admin role.
  2. Create a workspace. A new account has none, so the app asks you to name one. You become its owner.
  3. Create a collection, then a page inside it. Collections are the top level of the page tree and carry the default permissions of everything under them.

Registration is open to anyone who can reach the instance. There is no invite-only mode, no allowlist and no setting that turns sign-up off. On localhost that does not matter. Before you put the instance on a domain, read closing registration in the operations guide — the fix is a rule in your reverse proxy, and you have to add it yourself.

Configure

Settings go in infra/docker/.env, next to the compose file. Compose reads that file automatically when you run the command above from the repository root.

The two you must set:

Variable Purpose
AUTH_SECRET Signs sessions and auth tokens. openssl rand -hex 32. Never reuse it across instances, and keep a copy — losing it signs everyone out.
POSTGRES_PASSWORD Password for the bundled Postgres. openssl rand -hex 16. There is deliberately no default: weak database credentials are not a valid starting point.

The one you will need as soon as the instance is not on localhost:

Variable Purpose
BASE_URL The public URL of the instance, e.g. https://notes.example.com. Auth cookies, callback URLs and the cross-origin check are all derived from it. Wrong value means you can load the app but not stay signed in.

Both shipped compose files pass that whole file into the container, so anything in Configuration works by being written down there once. Four settings are pinned by the compose file and ignore what you put in .envPORT, STORAGE_DIR, SERVE_STATIC_DIR, NODE_ENV — because the port mapping and the attachment volume depend on them; the compose file says so at each one.

One gotcha: for most settings an empty value is not the same as an absent one. UNFURL= with nothing after it puts an empty string in the server's environment and the server refuses to boot, naming the variable. Delete the line rather than blanking it.

Where your data lives

Two named volumes, and nothing that matters outside them:

  • basalt-db — Postgres: documents, page tree, databases, users, permissions, comments, history. The source of truth.
  • basalt-files — uploaded images, at /data/attachments in the app container.

Back up both. A pg_dump on its own is not a complete backup, because the image bytes were never in the database — see Operations → Attachments.

If you installed Basalt before the basalt-files volume was shipped, check for it before your next upgrade. Attachments used to be written to /app/apps/server/.storage inside the container's own writable layer, with nothing mounted, so anything that replaced the app container — including the documented upgrade — deleted every uploaded image while leaving its database row behind. Operations → Attachments has the one-line check and how to rescue the bytes.

For a real deployment, consider putting attachments in object storage instead (STORAGE_DRIVER=s3), which takes the question of "did I back up that volume" off the table entirely.

Ports

Port What
8080 (host) The app: SPA, REST API, both WebSocket planes. Published.
3000 (container) What the server actually listens on. 8080:3000 maps it.
Postgres Not published to the host at all — reachable only from the app container.

Host port 8080 was picked to stay clear of DDEV's defaults. To use another one, override the mapping in a compose override file rather than editing the shipped compose file. compose.services.yml reads its two host ports from .env instead (BASALT_HOST_PORT, LITHIC_HOST_PORT), because in that topology a reverse proxy in front is the normal case and it has to be told where to reach them.

Optional: S3-compatible file storage

Object storage is the better place for attachments on any deployment you intend to keep, for one reason: it does not care that a deploy replaces your container, and it is backed up by whatever already backs up your bucket. The driver sets path-style addressing whenever S3_ENDPOINT is present, which is what every S3-compatible provider that is not AWS itself expects — MinIO, R2, Backblaze, Hetzner — so any of them works. Which provider is your decision; Basalt does not prefer one.

Five lines in infra/docker/.env, and Basalt uses the bucket after a restart:

STORAGE_DRIVER=s3
S3_BUCKET=basalt-attachments
S3_REGION=auto                                 # any value your provider accepts
S3_ENDPOINT=https://s3.example-provider.com    # omit this line for AWS S3
AWS_ACCESS_KEY_ID=…
AWS_SECRET_ACCESS_KEY=…

The credentials are not Basalt settings — the client uses the standard AWS provider chain, so an instance role or a mounted profile works instead of the two variables. The bucket must already exist; nothing here creates one, and a missing bucket surfaces as a failed upload rather than a failed boot.

To try it locally, compose.selfhost.yml ships a MinIO service behind the files profile:

docker compose -f infra/docker/compose.selfhost.yml --profile files up -d

Point the settings above at S3_ENDPOINT=http://minio:9000, use MINIO_ROOT_USER / MINIO_ROOT_PASSWORD as the two AWS credentials, and create the bucket once from inside the container (mc is in the image, with a local alias already configured — that is what its healthcheck uses):

docker compose -f infra/docker/compose.selfhost.yml --profile files \
  exec minio mc mb --ignore-existing local/basalt-attachments

Neither MinIO's API port nor its console is published to the host, on purpose: the only thing that needs to reach it is the app container, on the compose network. Treat this as a way to exercise the S3 path, not as a storage tier — it is one container on the same disk as everything else, so it buys you no durability over the basalt-files volume.

Next

  • Operations — reverse proxy and TLS, the two-domain deployment with Lithic, backups, upgrades, closing registration, troubleshooting.
  • Configuration — every setting and what it does.
  • API — automate the instance you just started.