Can I self-host Notion?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Docmost. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $120 a month you stop paying Notion — $1,440 a year on the Business plan, 5 seats assumed.
Why people pay for Notion
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Notion sells one surface for everything a team writes down. Docs, tables, tasks and the wiki are the same object underneath, so a meeting note can become a database row and a project board without anyone deciding which tool owns it. The bill buys that plus the parts nobody wants to run: sync that works on a phone on a train, sharing that a client can open without an account, and search across the whole workspace. Most of the money is per seat, which is why the invoice grows on the day someone joins rather than on the day anyone uses a feature.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | $0 per member per month. Priced per member like the paid tiers, with trial AI credits and a limit on collaborative blocks for teams. |
| Plus | $12/mo per seat | $12 per member per month billed monthly. $120 per member per year on annual billing, which the page shows as $10 per member per month. |
| Businessthe plan this page prices against | $24/mo per seat | $24 per member per month billed monthly. $240 per member per year on annual billing, shown as $20 per member per month. This is the tier that carries SAML SSO, private teamspaces and the bundled AI features. |
| Enterprise | quote only | Quote only. The pricing card reads Custom pricing and links to Contact Sales rather than a per-seat figure. |
Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: high
Replaced by Docmost
One project, named before the prompt, so you know what you are about to install.
Spaces, nested pages and an editor two people can type in at once, with every word in a Postgres you can dump.
The closest thing to the half of Notion most teams actually live in: spaces, nested pages, a real-time editor two people can type in at once, comments, and search. It imports a Notion export ZIP directly, so the migration is an upload rather than a rewrite. What it does not replace is the database half. Bases, Docmost's table-and-board feature, is a paid commercial add-on and is absent from the AGPL build this install runs, so if your Notion is mostly linked databases with rollups, you are moving half your workspace and leaving the other half behind.
The swap
You'd run
Docmost
ONE EVENING · ~90 min to running · 2048 MB RAM
Notion Business · 5 seats assumed · vendor list price · checked 2026-08-05 · source
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same Docmost: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
331 lines · 14,657 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Docmost 0.95.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Tell them why it matters when you ask: `<DOMAIN>` becomes `APP_URL`, and Docmost builds every
invitation link and every shared page link from `APP_URL`. Its A record must already point at
this server.
Docmost, PostgreSQL and Redis together need 2048 MB of RAM available and 10 GB free on /srv.
All three images publish amd64 and arm64. Measure all four before installing anything:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/docmost /srv/docmost/backups
sudo install -d -m 755 -o 1000 -g 1000 /srv/docmost/storage
sudo install -d -m 700 /srv/docmost/postgres /srv/docmost/redis
ls -la /srv/docmost
```
Assert: `ls -la` shows `backups` owned by the login user, `storage` owned by uid 1000, and
`postgres` and `redis` at mode `700` owned by root. Three different owners, three reasons.
Uploaded attachments go in `storage`, and the Docmost image runs as its base image's `node`
user, which is uid 1000. The PostgreSQL and Redis images each chown their own data directory
at start-up, so those two are left alone.
## 3. Secrets
Two secrets: the application secret Docmost signs sessions with, and the PostgreSQL password.
Generate both on the server. Do not print either, do not repeat them in your summary, and do
not put them in any log line. Upstream states the app refuses to start if `APP_SECRET` keeps
its shipped default, and asks for 32 characters minimum.
```bash
umask 077
cat > /srv/docmost/.env <<EOF
APP_URL=https://<DOMAIN>
APP_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/docmost/.env
umask 022
ls -l /srv/docmost/.env
```
Assert: the file exists with mode `-rw-------`. Hex rather than base64 for both, because
`DB_PASSWORD` is interpolated into a PostgreSQL connection string in the next step and a `/`
or a `+` in a URL is an argument nobody needs to have. Tell the user the file is the whole
configuration of this install and that step 8 backs it up.
## 4. compose.yml
```bash
cat > /srv/docmost/compose.yml <<'EOF'
# Docmost · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# installation ....... https://docmost.com/docs/installation
# variable reference . https://docmost.com/docs/self-hosting/environment-variables
# reverse proxy ...... https://docmost.com/docs/self-hosting/reverse-proxy
# caddy notes ........ https://docmost.com/docs/self-hosting/reverse-proxy/caddy
# image ............. https://github.com/docmost/docmost/blob/main/Dockerfile
#
# Three services: Docmost, the PostgreSQL that holds every page, and the Redis
# that carries the collaborative editor and the job queue. Upstream lists
# DATABASE_URL and REDIS_URL as required, so neither is a nice-to-have here.
# The PostgreSQL 18 image keeps its cluster under /var/lib/postgresql and
# chowns that directory itself on first start, and the Redis image chowns /data
# on every start, so step 2 leaves both root-owned and lets the images sort it
# out. Docmost runs as the node user of its base image, uid 1000, so the
# storage bind mount is created with that owner instead. Tags and digests were
# read from the registries on 2026-08-05; all three images publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: docmost-db
restart: unless-stopped
environment:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/docmost/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docmost -d docmost"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other containers.
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: docmost-redis
restart: unless-stopped
# appendonly writes every change to disk, and noeviction makes Redis refuse
# writes rather than silently drop a queued job when memory runs out.
command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- /srv/docmost/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
# No `ports:` at all: 6379 never leaves the compose network.
docmost:
image: docmost/docmost:0.95.0@sha256:41c8d777cf23c74e78f94e676aec328b7d7856f48df5e573543dac68d371e37c
container_name: docmost
restart: unless-stopped
env_file: /srv/docmost/.env
environment:
DATABASE_URL: postgresql://docmost:${DB_PASSWORD}@postgres:5432/docmost
REDIS_URL: redis://redis:6379
# Attachments land on this disk, under the bind mount below. No S3
# bucket, no Azure account, nothing extra to sign up for.
STORAGE_DRIVER: local
volumes:
- /srv/docmost/storage:/app/data/storage
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8092.
- "127.0.0.1:8092:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
EOF
cd /srv/docmost && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Compose reads `.env` from this directory to fill
`${DB_PASSWORD}` in two places, the PostgreSQL environment and the connection string, which is
why one generated value ends up in both without ever being typed twice.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-docmost
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Docmost · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docmost.com/docs/self-hosting/reverse-proxy/caddy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, and Docmost builds its invitation links and its shared
# page links from APP_URL, so the two have to say the same thing.
<DOMAIN> {
encode zstd gzip
# No X-Frame-Options here on purpose. Docmost decides its own frame
# header from IFRAME_EMBED_ALLOWED, and one set at this layer would
# override the application's answer without the application knowing.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# 8092 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. Upstream states the
# real-time editor rides WebSockets and stays read-only if the proxy
# does not pass the Upgrade and Connection headers. Caddy's reverse_proxy
# performs that upgrade with no extra directive, which is the reason this
# block is three lines rather than thirty.
reverse_proxy 127.0.0.1:8092
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-docmost, reload, and report what it objected to. Caddy requests the
certificate on the first request to the hostname and renews it by itself, so there is nothing
to schedule.
## 6. Firewall
Two ports open, both Caddy's. The commands are idempotent, so on a box Prompt Zero configured
they change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8092 stays closed because compose binds it to 127.0.0.1, and 5432 and 6379
stay closed because compose publishes no host port for them at all. Assert: `ufw status
verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule naming 8092, 5432
or 6379.
## 7. Start and verify
Docmost runs its own database migrations on the way up, so the first start is slower than the
ones after it.
```bash
cd /srv/docmost
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/workspace/public
```
Assert, all three, and print what you received for each. The loop ends printing `200`. The
health body contains `"status":"ok"` and reports both `database` and `redis` as `up`, which is
the whole reason to look at it rather than at `docker ps`. The third command prints `404`,
because no workspace exists yet. If any of the three misses, stop, run
`docker compose logs --tail 40 docmost`, `docker compose logs --tail 20 postgres` and
`docker compose logs --tail 20 redis`, and say which earlier step is the likely cause: a health
body with `redis` down points at step 4, and a `502` from Caddy points at step 5. A running
container is not success.
The first screen at https://<DOMAIN>/setup/register shows the heading `Create workspace` above
fields for a workspace name, a name, an email and a password of at least 8 characters.
STOP: tell the user to open https://<DOMAIN>/setup/register, create the workspace and their own
account there, and wait. Do not continue until they confirm. That first account becomes the
workspace owner.
Once they confirm, prove that first-run registration is now closed to everyone else:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/workspace/public
curl -sS -w '\n%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{}' https://<DOMAIN>/api/auth/setup
```
Assert: the first prints `200`, so the workspace exists. The second returns a body containing
`Workspace setup already completed.` and a 403 status, so nobody who finds this hostname can
create a second owner account. Both asserts must pass before you report success.
## 8. First backup and restore
Two artifacts. The database holds every page, space, user and permission. The file archive
holds the attachments plus the three files that rebuild the service around them.
```bash
cd /srv/docmost
docker compose exec -T postgres pg_dump -U docmost -d docmost | gzip > /srv/docmost/backups/docmost-db-$(date +%F).sql.gz
sudo tar -czf /srv/docmost/backups/docmost-files-$(date +%F).tar.gz -C /srv/docmost storage compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/docmost/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. Redis is not backed up on purpose: it
holds the editor's live session state and the job queue, not content, and it rebuilds itself.
A backup on the same disk is not a backup, so run this one from the user's machine:
```bash
mkdir -p ~/backups/docmost
scp vps:/srv/docmost/backups/* ~/backups/docmost/
```
To restore: `docker compose down`, `sudo rm -rf /srv/docmost/postgres /srv/docmost/storage`,
recreate both directories exactly as step 2 does, untar the file archive into /srv/docmost,
`docker compose up -d postgres`, wait for it to report healthy, then pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T postgres psql -U docmost -d docmost`, then
`docker compose up -d`. The `.env` inside the archive matters as much as the dump: PostgreSQL
keeps the password it was created with, and a restore with a different `DB_PASSWORD` produces
an authentication error that says nothing about passwords.
## 9. Updating later
New versions are listed at https://github.com/docmost/docmost/releases. Take both backup
artifacts first, then edit the image line in /srv/docmost/compose.yml to the new tag and its
digest:
```bash
cd /srv/docmost
docker compose pull
docker compose up -d
docker compose logs --tail 30 docmost
```
Docmost migrates its own database on the way up, so watch that log until it settles, then
re-run the health check from step 7 before calling the update done.
## 10. What will probably go wrong
Attachments, and not until later. On my first run I left /srv/docmost/storage owned by root,
the containers started, the health endpoint answered `"status":"ok"`, pages saved, and
everything looked finished. The first image a user dragged into a page failed with a server
error, and the only trace was an `EACCES` line in the Docmost log. Health checks the database
and Redis and never touches the storage directory, so nothing warns you. If uploads fail, run
`ls -ld /srv/docmost/storage` and confirm it is owned by uid 1000 before looking anywhere else.
## 11. Out of scope
- Do not configure SMTP. Docmost runs without mail, and invitations can be handed out as links
copied from the members page. Password resets are the thing the user gives up until they add
a mail provider.
- Do not switch `STORAGE_DRIVER` to s3 or azure. That is a second vendor account, and the
backup in step 8 assumes attachments are on this disk.
- Do not add a license key and do not enable the enterprise features. Bases, SSO, AI, audit
logs and SCIM are sold separately and are not part of this install.
- Do not set `AI_DRIVER`, `OPENAI_API_KEY` or any other model credential. Nothing here needs a
model to store a page.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing Docmost 0.95.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.
One thing to settle before step 1. `<DOMAIN>` becomes `APP_URL`, and Docmost builds every
invitation link and every shared page link out of `APP_URL`. Pick the hostname you intend to
keep, and point its A record at the server now, because the certificate cannot be issued for a
name that does not resolve.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 2048 MB is the
one to take seriously here: three services share this box, and the container that gets killed
when memory runs out is usually PostgreSQL, which looks like a database bug and is not.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/docmost /srv/docmost/backups
sudo install -d -m 755 -o 1000 -g 1000 /srv/docmost/storage
sudo install -d -m 700 /srv/docmost/postgres /srv/docmost/redis
ls -la /srv/docmost
```
You should see: `backups` owned by you, `storage` owned by uid 1000, and `postgres` and `redis`
at mode `drwx------` owned by root.
If you do not: leave those three owners alone, they are each deliberate. Attachments go into
`storage`, and the Docmost image runs as its base image's `node` user, which is uid 1000, so a
`storage` you own yourself is a directory the app cannot write to. `postgres` and `redis` stay
root-owned because both images chown their own data directory at start-up, and one you have
already chowned to yourself makes PostgreSQL refuse to initialise.
## 3. Secrets
Two secrets: the application secret Docmost signs sessions with, and the PostgreSQL password.
Both are generated here, on the server, and both go straight into a file only you can read.
Upstream states the app refuses to start if `APP_SECRET` keeps its shipped default, and asks
for 32 characters minimum.
```bash
umask 077
cat > /srv/docmost/.env <<EOF
APP_URL=https://<DOMAIN>
APP_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/docmost/.env
umask 022
ls -l /srv/docmost/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately into different shells. Run `chmod 600 /srv/docmost/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
both secrets, which is fine before the database exists and a problem afterwards: PostgreSQL
keeps the password it was created with, so a changed `DB_PASSWORD` against an existing data
directory produces an authentication failure in the Docmost log rather than anything that
mentions passwords.
Do not paste that file, either secret, or any command output containing them into this chat
window. Nothing in this install ever needs you to read a secret out loud.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/docmost/compose.yml <<'EOF'
# Docmost · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# installation ....... https://docmost.com/docs/installation
# variable reference . https://docmost.com/docs/self-hosting/environment-variables
# reverse proxy ...... https://docmost.com/docs/self-hosting/reverse-proxy
# caddy notes ........ https://docmost.com/docs/self-hosting/reverse-proxy/caddy
# image ............. https://github.com/docmost/docmost/blob/main/Dockerfile
#
# Three services: Docmost, the PostgreSQL that holds every page, and the Redis
# that carries the collaborative editor and the job queue. Upstream lists
# DATABASE_URL and REDIS_URL as required, so neither is a nice-to-have here.
# The PostgreSQL 18 image keeps its cluster under /var/lib/postgresql and
# chowns that directory itself on first start, and the Redis image chowns /data
# on every start, so step 2 leaves both root-owned and lets the images sort it
# out. Docmost runs as the node user of its base image, uid 1000, so the
# storage bind mount is created with that owner instead. Tags and digests were
# read from the registries on 2026-08-05; all three images publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: docmost-db
restart: unless-stopped
environment:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/docmost/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docmost -d docmost"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other containers.
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: docmost-redis
restart: unless-stopped
# appendonly writes every change to disk, and noeviction makes Redis refuse
# writes rather than silently drop a queued job when memory runs out.
command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- /srv/docmost/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
# No `ports:` at all: 6379 never leaves the compose network.
docmost:
image: docmost/docmost:0.95.0@sha256:41c8d777cf23c74e78f94e676aec328b7d7856f48df5e573543dac68d371e37c
container_name: docmost
restart: unless-stopped
env_file: /srv/docmost/.env
environment:
DATABASE_URL: postgresql://docmost:${DB_PASSWORD}@postgres:5432/docmost
REDIS_URL: redis://redis:6379
# Attachments land on this disk, under the bind mount below. No S3
# bucket, no Azure account, nothing extra to sign up for.
STORAGE_DRIVER: local
volumes:
- /srv/docmost/storage:/app/data/storage
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8092.
- "127.0.0.1:8092:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
EOF
cd /srv/docmost && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/docmost/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal,
so run `rm /srv/docmost/compose.yml` and paste the block again in one go. Compose reads `.env`
from this directory to fill `${DB_PASSWORD}` in two places, the PostgreSQL environment and the
connection string, which is how one generated value ends up in both without you typing it.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-docmost
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Docmost · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docmost.com/docs/self-hosting/reverse-proxy/caddy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, and Docmost builds its invitation links and its shared
# page links from APP_URL, so the two have to say the same thing.
<DOMAIN> {
encode zstd gzip
# No X-Frame-Options here on purpose. Docmost decides its own frame
# header from IFRAME_EMBED_ALLOWED, and one set at this layer would
# override the application's answer without the application knowing.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# 8092 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. Upstream states the
# real-time editor rides WebSockets and stays read-only if the proxy
# does not pass the Upgrade and Connection headers. Caddy's reverse_proxy
# performs that upgrade with no extra directive, which is the reason this
# block is three lines rather than thirty.
reverse_proxy 127.0.0.1:8092
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-docmost /etc/caddy/Caddyfile`, reload,
and paste again. The most common cause is a `<DOMAIN>` you replaced in the site line but not in
the comment above it, or the other way round; only the site line matters to Caddy, but a
half-replaced block is a sign the paste was edited by hand mid-flight.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8092`, `5432` or `6379`.
If you do not: delete anything for those three with `sudo ufw delete allow 8092`. 8092 is bound
to 127.0.0.1 by the compose file, and 5432 and 6379 are never published at all, so neither
database has a host port a firewall rule could apply to. 80/tcp is there to redirect to HTTPS
and to answer the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which
Caddy offers by default. `Status: inactive` is a different problem: Prompt Zero left this
firewall enabled, so something has turned it off since, and `sudo ufw enable` puts it back
before you go any further.
## 7. Start and verify
Docmost runs its own database migrations on the way up, so the first start is slower than every
one after it.
```bash
cd /srv/docmost
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/workspace/public
```
You should see, in order: the loop reaching `200`, a JSON object containing `"status":"ok"`
with `database` and `redis` both `up`, then `404`.
If you do not: the `404` is the one worth understanding. It means the API is up and telling you
no workspace exists yet, which is exactly right at this point, so seeing it is good news. If the
loop never reaches `200`, run `docker compose logs --tail 20 postgres` first, because a database
that never reports healthy is step 2 done wrong, and `docker compose logs --tail 40 docmost`
second. A `502` from Caddy with healthy containers points at step 5 instead.
The first screen at https://<DOMAIN>/setup/register shows the heading `Create workspace` above
fields for a workspace name, a name, an email and a password of at least 8 characters. Open it,
create your workspace and your own account, and come back. That account becomes the workspace
owner. Then prove that first-run registration is now closed to everyone else:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/workspace/public
curl -sS -w '\n%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{}' https://<DOMAIN>/api/auth/setup
```
You should see: `200` from the first, then a 403 body containing
`Workspace setup already completed.` from the second.
If you do not: a second `404` means the account was not actually created, so go back to the
setup page. Anything other than that 403 message from the second command is the one result here
worth stopping for, because it would mean a stranger who finds this hostname can still create an
owner account on your wiki. A running container is not success; these two lines are.
## 8. First backup and restore
Two artifacts. The database holds every page, space, user and permission. The file archive holds
the attachments plus the three files that rebuild the service around them.
```bash
cd /srv/docmost
docker compose exec -T postgres pg_dump -U docmost -d docmost | gzip > /srv/docmost/backups/docmost-db-$(date +%F).sql.gz
sudo tar -czf /srv/docmost/backups/docmost-files-$(date +%F).tar.gz -C /srv/docmost storage compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/docmost/backups/
```
You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently. Redis is not in the backup on purpose, as
it holds live editor sessions and queued jobs rather than content.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/docmost
scp vps:/srv/docmost/backups/* ~/backups/docmost/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/docmost/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is a page you wrote five minutes ago:
```bash
cd /srv/docmost
docker compose down
sudo rm -rf /srv/docmost/postgres
sudo install -d -m 700 /srv/docmost/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/docmost/backups/docmost-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U docmost -d docmost
docker compose up -d
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/workspace/public
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then `200` from the last command, and
your pages still there when you reload the browser.
If you do not: `role "docmost" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. The `.env` in the file archive
matters as much as the dump, because PostgreSQL keeps the password it was created with and a
restore under a different `DB_PASSWORD` fails with an authentication error that never mentions
passwords.
## 9. Updating later
New versions are listed at https://github.com/docmost/docmost/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/docmost/compose.yml to the new tag and its
digest.
```bash
cd /srv/docmost
docker compose pull
docker compose up -d
docker compose logs --tail 30 docmost
```
You should see: migration output, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, and open one real page as well,
because a service that answers `"status":"ok"` on health can still be failing to render content
if a migration stopped halfway.
## 10. What will probably go wrong
Attachments, and not until later. On my first run I left /srv/docmost/storage owned by root, the
containers started, the health endpoint answered `"status":"ok"`, pages saved, and everything
looked finished. The first image I dragged into a page failed with a server error, and the only
trace was an `EACCES` line in the Docmost log. Health checks the database and Redis and never
touches the storage directory, so nothing warns you. If uploads fail, run
`ls -ld /srv/docmost/storage` and confirm it is owned by uid 1000 before looking anywhere else.
## 11. Out of scope
- Do not configure SMTP. Docmost runs without mail, and invitations can be handed out as links
copied from the members page. Password resets are the thing you give up until you add a mail
provider.
- Do not switch `STORAGE_DRIVER` to s3 or azure. That is a second vendor account, and the backup
in step 8 assumes attachments are on this disk.
- Do not add a license key and do not enable the enterprise features. Bases, SSO, AI, audit logs
and SCIM are sold separately and are not part of this install.
- Do not set `AI_DRIVER`, `OPENAI_API_KEY` or any other model credential. Nothing here needs a
model to store a page.326 lines · 14,985 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Docmost 0.95.0, with the PostgreSQL and Redis it needs, under ~/selfhost/docmost,
answering at http://localhost:8092.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install at all.
The wiki answers on this computer and nowhere else. Nobody they invite can reach it, their
own phone cannot, and every shared-page link Docmost builds begins with http://localhost:8092,
which means "this computer" to whoever opens it. A team of one is the honest use of this path.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Docmost plus PostgreSQL plus Redis needs
2048 MB of RAM available and 10 GB free on the home disk, and all three images publish amd64
and arm64. If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers
and stop. Do not install and hope.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/docmost/storage ~/selfhost/docmost/backups
if [ "$(uname -s)" = "Linux" ]; then
sudo chown -R 1000:1000 ~/selfhost/docmost/storage
fi
ls -la ~/selfhost/docmost
```
Assert: `ls -la` shows `storage` and `backups`. Attachments land in `storage`, and Docmost runs
as its base image's `node` user, uid 1000, so on Linux that folder is handed to uid 1000. The
guard skips on macOS and Windows, where Docker Desktop's file sharing handles it.
## 4. Secrets
Two secrets: the application secret Docmost signs sessions with, and the PostgreSQL password.
Generate both here, print neither, and keep both out of your summary and any log line.
Upstream states the app refuses to start if `APP_SECRET` keeps its shipped default.
```bash
umask 077
cat > ~/selfhost/docmost/.env <<EOF
APP_URL=http://localhost:8092
APP_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/docmost/.env
umask 022
ls -l ~/selfhost/docmost/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same everywhere. Hex rather than base64, because `DB_PASSWORD` goes into a PostgreSQL
connection string in step 5. On Windows those mode bits are advisory: NTFS does not enforce
them, and the real boundary is the user's own Windows account.
## 5. compose.yml
```bash
cat > ~/selfhost/docmost/compose.yml <<'EOF'
# Docmost · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# installation ....... https://docmost.com/docs/installation
# variable reference . https://docmost.com/docs/self-hosting/environment-variables
# image ............. https://github.com/docmost/docmost/blob/main/Dockerfile
#
# Three services on the computer you are sitting at. Paths are relative to
# ~/selfhost/docmost/, so one file works on macOS, Linux and Windows. The
# database and Redis are named volumes rather than folders you can open,
# because both images chown their own data directory and a home-directory
# bind mount cannot grant that on Windows. Attachments stay a real folder,
# ./storage, owned by uid 1000, the node user Docmost runs as. Digests read
# on 2026-08-05; all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: docmost-db
restart: unless-stopped
environment:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- docmost-pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docmost -d docmost"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 stays on the compose network.
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: docmost-redis
restart: unless-stopped
# appendonly persists every change; noeviction makes Redis refuse writes
# rather than silently drop a queued job when memory runs out.
command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- docmost-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
# No `ports:` at all: 6379 stays on the compose network.
docmost:
image: docmost/docmost:0.95.0@sha256:41c8d777cf23c74e78f94e676aec328b7d7856f48df5e573543dac68d371e37c
container_name: docmost
restart: unless-stopped
env_file: ./.env
environment:
DATABASE_URL: postgresql://docmost:${DB_PASSWORD}@postgres:5432/docmost
REDIS_URL: redis://redis:6379
# Attachments land in ./storage. No S3 or Azure account.
STORAGE_DRIVER: local
volumes:
- ./storage:/app/data/storage
ports:
# Loopback only: no other device can reach 8092.
- "127.0.0.1:8092:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
docmost-pgdata:
docmost-redis:
EOF
cd ~/selfhost/docmost && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Compose fills `${DB_PASSWORD}` from `.env` in this
directory, so one generated value reaches both PostgreSQL and the connection string.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. Each is a decision:
- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
http://localhost as a secure context anyway, so the editor still works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.
8092 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, nobody on the internet. Confirm it:
```bash
grep -n '127.0.0.1' ~/selfhost/docmost/compose.yml
```
Assert: one line, `- "127.0.0.1:8092:3000"`. PostgreSQL and Redis publish no host port at all.
## 7. Start and verify
Docmost migrates its own database on the way up, so the first start is the slow one.
```bash
cd ~/selfhost/docmost
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8092/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8092/api/health
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8092/api/workspace/public
```
Assert all three, and print what you received for each: the loop ends on `200`; the health body
contains `"status":"ok"` and reports both `database` and `redis` as `up`; the third prints
`404`, because no workspace exists yet. If any of the three misses, stop, run
`docker compose logs --tail 40 docmost` and `docker compose logs --tail 20 postgres`, and name
the likely cause: an empty `DB_PASSWORD` from step 4 leaves PostgreSQL refusing to start, and a
log still in migrations wants more time. If `port is already allocated` came back, find what
holds 8092 (`lsof -nP -iTCP:8092 -sTCP:LISTEN`, or `netstat -ano | findstr :8092` on Windows)
and stop until the user frees it. A running container is not success.
The first screen at http://localhost:8092/setup/register shows the heading `Create workspace`
above fields for a workspace name, a name, an email and a password of at least 8 characters.
STOP: tell the user to open that page, create the workspace and their own account, and wait. Do
not continue until they confirm. That first account becomes the workspace owner.
Once they confirm, prove that first-run registration is now closed:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8092/api/workspace/public
curl -sS -w '\n%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{}' http://localhost:8092/api/auth/setup
```
Assert: the first prints `200`, so the workspace exists. The second returns a 403 whose body
contains `Workspace setup already completed.` Both must pass before you report success.
## 8. First backup and restore
Two artifacts: a database dump with every page, space and user, and a file archive with the
attachments and the two files that rebuild it.
```bash
cd ~/selfhost/docmost
docker compose exec -T postgres pg_dump -U docmost -d docmost | gzip > ~/selfhost/docmost/backups/docmost-db-$(date +%F).sql.gz
tar -C ~/selfhost/docmost -czf ~/selfhost/docmost/backups/docmost-files-$(date +%F).tar.gz storage compose.yml .env
ls -lh ~/selfhost/docmost/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped:
`pg_dump` snapshots a running database consistently, and Redis stays out of the backup because
it holds live editor sessions and queued jobs, not content.
Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a
folder their sync service watches or a USB stick, and copy both there with `cp`. In Git Bash a
Windows drive is written `/d/Backups`, not `D:\Backups`. Assert: the user confirms both files
are there. If they have neither, say that this install has no backup.
To restore, in this order. `cd ~/selfhost/docmost`, untar the file archive there first, so
compose.yml and .env are back before any container starts: PostgreSQL takes `DB_PASSWORD` from
.env the moment it initialises an empty volume, and a different password gives an
authentication error that says nothing about passwords. Then `docker compose down -v`, the one
place `-v` belongs, `docker compose up -d postgres`, wait 30 seconds for healthy, pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T postgres psql -U docmost -d docmost`,
then `docker compose up -d`. Open a page and check its text and attachments are there.
## 9. Updating later
New versions are listed at https://github.com/docmost/docmost/releases. Take both backups
first, then edit the image line in ~/selfhost/docmost/compose.yml to the new tag and digest:
```bash
cd ~/selfhost/docmost
docker compose pull
docker compose up -d
docker compose logs --tail 30 docmost
```
Watch that log until it settles, then re-run step 7's health check before calling it done.
## 10. What will probably go wrong
I left a page open, closed the laptop, and came back an hour later to an editor that would not
take a keystroke. It looked like data loss. It was not: the editor holds its session over a
WebSocket, sleeping the machine kills that socket, and the page then sits there looking
editable while nothing typed into it goes anywhere. Reloading the tab fixed it every time. If
it does not, run `cd ~/selfhost/docmost && docker compose ps` first: Docker Desktop does not
always come back after a reboot, and `restart: unless-stopped` acts only once its daemon is
up.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8092 to 0.0.0.0 so a phone can reach it. `APP_URL` would still say localhost,
and the wiki would be readable by every device on whatever network this machine joins next.
- Do not configure SMTP. Invitations can be handed out as links from the members page.
- Do not add a license key. Bases, SSO, AI, audit logs and SCIM are sold separately and are not
part of this install.compose.local.ymlthe services, pinned · local layout72 lines
# Docmost · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# installation ....... https://docmost.com/docs/installation
# variable reference . https://docmost.com/docs/self-hosting/environment-variables
# image ............. https://github.com/docmost/docmost/blob/main/Dockerfile
#
# Three services on the computer you are sitting at. Paths are relative to
# ~/selfhost/docmost/, so one file works on macOS, Linux and Windows. The
# database and Redis are named volumes rather than folders you can open,
# because both images chown their own data directory and a home-directory
# bind mount cannot grant that on Windows. Attachments stay a real folder,
# ./storage, owned by uid 1000, the node user Docmost runs as. Digests read
# on 2026-08-05; all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: docmost-db
restart: unless-stopped
environment:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- docmost-pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docmost -d docmost"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 stays on the compose network.
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: docmost-redis
restart: unless-stopped
# appendonly persists every change; noeviction makes Redis refuse writes
# rather than silently drop a queued job when memory runs out.
command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- docmost-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
# No `ports:` at all: 6379 stays on the compose network.
docmost:
image: docmost/docmost:0.95.0@sha256:41c8d777cf23c74e78f94e676aec328b7d7856f48df5e573543dac68d371e37c
container_name: docmost
restart: unless-stopped
env_file: ./.env
environment:
DATABASE_URL: postgresql://docmost:${DB_PASSWORD}@postgres:5432/docmost
REDIS_URL: redis://redis:6379
# Attachments land in ./storage. No S3 or Azure account.
STORAGE_DRIVER: local
volumes:
- ./storage:/app/data/storage
ports:
# Loopback only: no other device can reach 8092.
- "127.0.0.1:8092:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
docmost-pgdata:
docmost-redis:agent-readable mirror: /self-host/notion.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned74 lines
# Docmost · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# installation ....... https://docmost.com/docs/installation
# variable reference . https://docmost.com/docs/self-hosting/environment-variables
# reverse proxy ...... https://docmost.com/docs/self-hosting/reverse-proxy
# caddy notes ........ https://docmost.com/docs/self-hosting/reverse-proxy/caddy
# image ............. https://github.com/docmost/docmost/blob/main/Dockerfile
#
# Three services: Docmost, the PostgreSQL that holds every page, and the Redis
# that carries the collaborative editor and the job queue. Upstream lists
# DATABASE_URL and REDIS_URL as required, so neither is a nice-to-have here.
# The PostgreSQL 18 image keeps its cluster under /var/lib/postgresql and
# chowns that directory itself on first start, and the Redis image chowns /data
# on every start, so step 2 leaves both root-owned and lets the images sort it
# out. Docmost runs as the node user of its base image, uid 1000, so the
# storage bind mount is created with that owner instead. Tags and digests were
# read from the registries on 2026-08-05; all three images publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: docmost-db
restart: unless-stopped
environment:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /srv/docmost/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docmost -d docmost"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other containers.
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: docmost-redis
restart: unless-stopped
# appendonly writes every change to disk, and noeviction makes Redis refuse
# writes rather than silently drop a queued job when memory runs out.
command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
volumes:
- /srv/docmost/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
# No `ports:` at all: 6379 never leaves the compose network.
docmost:
image: docmost/docmost:0.95.0@sha256:41c8d777cf23c74e78f94e676aec328b7d7856f48df5e573543dac68d371e37c
container_name: docmost
restart: unless-stopped
env_file: /srv/docmost/.env
environment:
DATABASE_URL: postgresql://docmost:${DB_PASSWORD}@postgres:5432/docmost
REDIS_URL: redis://redis:6379
# Attachments land on this disk, under the bind mount below. No S3
# bucket, no Azure account, nothing extra to sign up for.
STORAGE_DRIVER: local
volumes:
- /srv/docmost/storage:/app/data/storage
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8092.
- "127.0.0.1:8092:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthyCaddyfilethe hostname and TLS32 lines
# Docmost · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docmost.com/docs/self-hosting/reverse-proxy/caddy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, and Docmost builds its invitation links and its shared
# page links from APP_URL, so the two have to say the same thing.
<DOMAIN> {
encode zstd gzip
# No X-Frame-Options here on purpose. Docmost decides its own frame
# header from IFRAME_EMBED_ALLOWED, and one set at this layer would
# override the application's answer without the application knowing.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# 8092 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. Upstream states the
# real-time editor rides WebSockets and stays read-only if the proxy
# does not pass the Upgrade and Connection headers. Caddy's reverse_proxy
# performs that upgrade with no extra directive, which is the reason this
# block is three lines rather than thirty.
reverse_proxy 127.0.0.1:8092
}install.shthe same install, no agent158 lines
#!/usr/bin/env bash
# Docmost · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=wiki.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://docmost.com/docs/installation
# https://docmost.com/docs/self-hosting/environment-variables
# https://docmost.com/docs/self-hosting/reverse-proxy
# https://docmost.com/docs/self-hosting/reverse-proxy/caddy
#
# Two secrets are generated here, on this machine: the application secret and
# the PostgreSQL password. Both go into /srv/docmost/.env with mode 600 and
# neither is ever printed.
#
# DOMAIN_HOST is also APP_URL. Docmost builds every invitation link and every
# shared page link out of APP_URL, so choose the hostname you intend to keep.
#
# The last step of this script leaves the workspace uncreated on purpose: only
# a human at a browser can fill in the setup form, and the closing summary says
# how to confirm that registration closed behind them.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/docmost}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. wiki.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; Docmost plus PostgreSQL plus Redis wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# Three owners, three reasons. Attachments live in storage, which the Docmost
# image writes as its base image's node user, uid 1000. PostgreSQL and Redis
# each chown their own data directory at start-up, so those two stay root-owned
# and untouched.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 755 -o 1000 -g 1000 "$APP_DIR/storage"
sudo install -d -m 700 "$APP_DIR/postgres" "$APP_DIR/redis"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64 for both: DB_PASSWORD is interpolated into a
# PostgreSQL connection string, and a / or a + inside a URL is an argument
# nobody needs to have. Read them later with
# sudo grep -E 'APP_SECRET|DB_PASSWORD' /srv/docmost/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
APP_URL=https://${DOMAIN_HOST}
APP_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-docmost"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and none of 8092, 5432 or 6379 is one of them -------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8092, 5432 and 6379 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# Docmost runs its own database migrations on the way up, so the first start is
# slower than every one after it.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/api/health"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/health" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/api/health answered ${code:-nothing}. Check: docker compose logs --tail 40 docmost"
curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q '"status":"ok"' \
|| die "/api/health answered 200 without status ok. Check: docker compose logs --tail 40 docmost"
# No workspace exists yet, and upstream answers 404 for that. Anything else here
# means the request is not reaching Docmost.
setup_state="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/workspace/public" || true)"
[ "$setup_state" = "404" ] || die "/api/workspace/public returned ${setup_state}, not 404. Stop and investigate."
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U docmost -d docmost | gzip > "$APP_DIR/backups/docmost-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/docmost-files-${STAMP}.tar.gz" -C "$APP_DIR" storage compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/docmost-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
Docmost is answering at https://${DOMAIN_HOST}/api/health
1. Open https://${DOMAIN_HOST}/setup/register in a browser. The first
screen reads "Create workspace" and asks for a workspace name, your
name, your email and a password of at least 8 characters. That first
account becomes the workspace owner.
2. Then confirm nobody else can claim one, from this server:
curl -sS -w '\n%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{}' \\
https://${DOMAIN_HOST}/api/auth/setup
It must answer 403 with "Workspace setup already completed." Until
step 1 is done, a stranger who finds this hostname can create the
owner account instead of you, so do not leave it overnight.
3. Your two secrets are in $APP_DIR/.env, mode 600, and were not printed
here. Read them yourself with
sudo grep -E 'APP_SECRET|DB_PASSWORD' $APP_DIR/.env
4. First backup written to $APP_DIR/backups: a database dump and a file
archive holding the attachments and the config. They are on the same
disk as the data, which is not a backup. Copy them somewhere else
tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Notion.
- You get the wiki half of Notion, not the database half. Bases, the tables, boards and formulas Notion users think of as databases, is a paid commercial feature that the free AGPL-3.0 build does not include. If your workspace is mostly linked databases with rollups, half of it has nowhere to land.
- The license is AGPL-3.0, which is open source, but the same image also carries enterprise features that stay locked without a key you buy: SSO, AI, audit logs, SCIM provisioning and Bases. Nothing expires and nothing phones home, and the free feature set is the one you keep.
- You own a PostgreSQL and a Redis. The pages are rows, so a backup is a pg_dump plus the attachments directory, not a folder you can copy. Redis is not backed up at all, because it holds live editor sessions and a job queue rather than content.
- No mail until you set it up. Docmost runs without SMTP and invitations can be handed out as links copied from the members page, but password reset email is the thing you do not have until you add a mail provider.
- A Notion export ZIP imports directly, which makes the move an upload rather than a rewrite. What does not come across is anything Notion does that a wiki does not: forms, the template gallery, synced database views, and the automations built on top of them.
Where this came from
“Bases is a commercial feature available on all self-hosted paid plans. An active Business or Enterprise license is required. Bases is not available in the free, open source edition.”
- Bases, the Notion-style database inside Docmost, is a commercial feature that requires a Business or Enterprise license and is absent from the free open source edition. source
- The open source edition is AGPL-3.0 and covers the core documentation and collaboration features; enterprise features are unlocked by a license key entered under Settings. source
- APP_SECRET must be replaced with a random secret of at least 32 characters or the app will not start, DATABASE_URL and REDIS_URL are both required, and a health endpoint answers at /api/health. source
- The real-time editor relies on WebSockets, and a reverse proxy that does not forward the Upgrade and Connection headers leaves the page editor in read-only mode. source
- A Notion workspace export ZIP can be imported straight into a Docmost space, preserving the page structure. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Notion?
Not Notion itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Docmost. Spaces, nested pages and an editor two people can type in at once, with every word in a Postgres you can dump. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Notion?
Docmost. Spaces, nested pages and an editor two people can type in at once, with every word in a Postgres you can dump. The closest thing to the half of Notion most teams actually live in: spaces, nested pages, a real-time editor two people can type in at once, comments, and search. It imports a Notion export ZIP directly, so the migration is an upload rather than a rewrite. What it does not replace is the database half. Bases, Docmost's table-and-board feature, is a paid commercial add-on and is absent from the AGPL build this install runs, so if your Notion is mostly linked databases with rollups, you are moving half your workspace and leaving the other half behind. Docmost is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Notion?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Docmost itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Notion Business, $120/mo — $1,440 a year, 5 seats assumed.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Docmost install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Docmost on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Docmost on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Only this computer can open the wiki, and every share link it generates starts with http://localhost:8092, so on the local path a team of one is the honest use. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.