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
- Register. The registration form creates an account. Nothing else happens: there is no first-user ceremony and no admin role.
- Create a workspace. A new account has none, so the app asks you to name one. You become its owner.
- 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
localhostthat 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 .env —
PORT, 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/attachmentsin 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-filesvolume was shipped, check for it before your next upgrade. Attachments used to be written to/app/apps/server/.storageinside 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.