Can I self-host Roam Research?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called SiYuan. It takes one prompt, a 1024 MB VPS, and about 10 minutes. That is $15 a month you stop paying Roam Research — $180 a year on the Pro plan.

Why people pay for Roam Research

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.

Roam sells a way of writing that did not exist before it: every paragraph is an addressable block, you reference blocks instead of copying them, and a daily note gives you somewhere to put a thought without deciding where it belongs first. People who think this way do not want a different note app, they want that one, and the subscription buys a hosted graph that is already there, on every device, with the mobile apps and the sharing and the backups handled by somebody else. There is no free tier under it, only a 31-day trial, so the graph and the bill start on the same day.

Roam Research plans and list prices
PlanList priceWhat it buys
Prothe plan this page prices against$15/mo$15 a month billed monthly, which the same panel also prints as $180 a year. Paying annually instead is $165 a year, shown as $13.75 a month. A 31-day free trial is offered, and the panel's own footnote says it is valid only if you have never had a plan.
Believerquote only$500 paid once, covering five years, which the panel prints as $8.33 a month or $100 a year. It lists everything in Pro plus first access to new features, community calls with the team and priority support. Recorded with no monthly price because there is no monthly billing to record.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by SiYuan

One project, named before the prompt, so you know what you are about to install.

A block-referencing outliner with daily notes and two-way links, served from one container to whatever browser you open next.

The only one here that keeps the shape and the server. Blocks with real block references, two-way links, daily notes and an outliner that zooms into any block, served from one container so every device opens the same graph in a browser exactly the way the hosted product works. What you take on is stated by upstream rather than discovered later: this deployment is browser-only, so the SiYuan apps in the app stores are not part of it, Markdown import is unavailable here, and the sync settings inside the app belong to a paid SiYuan account you do not need and should not turn on. One lock screen code is the whole gate, and the graph is JSON files you can read on your own disk.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. SiYuanour pickONE COMMAND— setup effort 1 of 4

    A block-referencing outliner with daily notes and two-way links, served from one container to whatever browser you open next.

    The only one here that keeps the shape and the server. Blocks with real block references, two-way links, daily notes and an outliner that zooms into any block, served from one container so every device opens the same graph in a browser exactly the way the hosted product works. What you take on is stated by upstream rather than discovered later: this deployment is browser-only, so the SiYuan apps in the app stores are not part of it, Markdown import is unavailable here, and the sync settings inside the app belong to a paid SiYuan account you do not need and should not turn on. One lock screen code is the whole gate, and the graph is JSON files you can read on your own disk.

  2. TriliumONE COMMAND— setup effort 1 of 4

    A personal knowledge base with a note tree, relations and scripting, keeping every note in one SQLite file you can copy.

    The better answer if what you actually kept from Roam was the writing rather than the graph. Trilium is a tree of notes with relations, attachments and scripting in one container over one SQLite file, and it is a calmer, more finished piece of software than anything else on this list. It is also honestly a different tool: notes are pages in a hierarchy that you link between, not blocks you reference and transclude, so the daily-note-plus-block-ref habit does not survive the move. Ranked second for a Roam refugee and first for anyone who was quietly using Roam as an outline-shaped filing cabinet.

The swap

You're paying

Roam Research

$15/mo · $180/yr

is replaced by

You'd run

SiYuan

ONE COMMAND · ~10 min to running · 1024 MB RAM

Roam Research Pro · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
1024 MBfloor from upstream docs — not measured by us yet
Disk
5 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
~10 minunder 10 minutes, through the first backup

The prompt

Two paths to the same SiYuan: 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.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

322 lines · 14,870 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

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 SiYuan 3.7.3 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.
Its A record must already point at this server.

Say two things to the user before anything installs, because together they decide whether they
want this at all. SiYuan is the outliner shape: blocks, block references, two-way links and daily
notes, edited in a browser. The container serves that same application over HTTP, and upstream
states plainly that the Docker deployment does not accept desktop or mobile application
connections and supports browsers only. So every device opens the same workspace on this server,
the way a hosted graph works, and the SiYuan apps in the app stores are not part of that.
Second: the sync feature inside SiYuan is not how this install keeps devices together, and they
should not turn it on. There is one workspace on one server, and the kernel gates every sync
provider it offers, S3, WebDAV and a plain local folder alike, behind a paid SiYuan account.

SiYuan needs 1024 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and arm64.
Measure all four:

```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 1024 MB or free disk is under 5 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 hostname that does not resolve.

## 2. Layout

Three directories. The workspace belongs to uid 1000, because that is the account the image
entrypoint creates and re-execs the kernel as.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/siyuan /srv/siyuan/backups
sudo install -d -m 700 -o 1000 -g 1000 /srv/siyuan/workspace
ls -la /srv/siyuan
```

Assert: `ls -la` shows `backups` owned by the login user and `workspace` at mode `700` owned by
uid `1000`. The entrypoint chowns that directory to `PUID:PGID` on every start, so setting it now
matches what the container will do. Everything SiYuan keeps lives under `workspace`, and nothing
is written outside /srv/siyuan.

## 3. Secrets

One secret: the lock screen code. It is the only thing standing between this hostname and every
note on it, so treat it the way you would treat a vault password. Generate it on the server. Do
not print it, do not repeat it in your summary, and do not put it in any log line.

```bash
umask 077
cat > /srv/siyuan/.env <<EOF
SIYUAN_ACCESS_AUTH_CODE=$(openssl rand -hex 32)
EOF
chmod 600 /srv/siyuan/.env
umask 022
ls -l /srv/siyuan/.env
```

Assert: the file exists with mode `-rw-------`. Upstream documents this value both as an
`--accessAuthCode` flag and as this environment variable, and says the command line wins when
both are set. The env file is used here on purpose: a value on the command line is readable in
every process listing inside the container.

There is no account behind this code. SiYuan has one workspace and one gate, and whoever holds
the code holds the notebook.

## 4. compose.yml

```bash
cat > /srv/siyuan/compose.yml <<'EOF'
# SiYuan · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker deployment .. https://github.com/siyuan-note/siyuan/blob/v3.7.3/README.md
#   image entrypoint ... https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/entrypoint.sh
#   kernel http api .... https://github.com/siyuan-note/siyuan/blob/v3.7.3/docs/API.md
#   access gate ........ https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/model/session.go
#
# One service and one workspace directory. The `command:` line is not optional:
# from v3.7.0 the kernel is a subcommand tree and the entrypoint pulls
# --workspace out of the arguments, puts it back in front of whatever is left,
# and hands the rest to the kernel, so `serve` has to be written here.
#
# The lock screen code arrives as SIYUAN_ACCESS_AUTH_CODE out of the env file
# instead of on the command line, where every process listing inside the
# container would carry it. Upstream documents both spellings and says the
# command line wins when both are set, so only one is used here.
#
# PUID and PGID are the ids the entrypoint creates a user for and re-execs as,
# and it chowns the mounted workspace to them on every start. SIYUAN_LANG pins
# the interface language so the check in step 7 has one right answer; upstream
# states it is applied on every start-up and overrides the language chosen in
# Settings. Tag and digest read from Docker Hub on 2026-08-06; the image
# publishes amd64, arm64, armv7 and armv8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  siyuan:
    image: b3log/siyuan:v3.7.3@sha256:908faf8ec55d391d95244982c081edabbaec118552d01fc3dc189d098cc0ffc8
    container_name: siyuan
    restart: unless-stopped
    command: ["serve", "--workspace=/siyuan/workspace"]
    env_file: /srv/siyuan/.env
    environment:
      PUID: "1000"
      PGID: "1000"
      TZ: "UTC"
      SIYUAN_LANG: "en"
    volumes:
      # conf/, data/ and temp/ appear under here on the first start. Notebooks
      # are folders of .sy JSON files under data/, and anything pasted into a
      # note lands beside them in data/assets.
      - /srv/siyuan/workspace:/siyuan/workspace
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8141.
      - "127.0.0.1:8141:6806"
    healthcheck:
      # Registered without the auth middleware, so it answers whether or not
      # anyone has unlocked the workspace yet.
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:6806/api/system/version"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 60s
EOF
cd /srv/siyuan && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, no database container. The
kernel keeps its index in SQLite inside the workspace, and the notes are JSON files beside it.

## 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-siyuan
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# SiYuan · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/siyuan-note/siyuan/blob/v3.7.3/README.md 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. Upstream asks for
# a reverse proxy in front of 6806 that also carries the /ws WebSocket route,
# and asks you not to reach the app through a URL rewrite because the rewrite
# breaks its authentication. Caddy's reverse_proxy upgrades WebSocket
# connections on its own and rewrites nothing, so one directive covers both.

<DOMAIN> {
	# The editor bundle and the kernel's JSON responses compress well. Caddy's
	# default encode matcher covers text, JSON, JavaScript and SVG only, so an
	# image pasted into a note passes through untouched.
	encode zstd gzip

	# SiYuan marks its session cookie HttpOnly, and marks it Secure only when
	# the kernel itself terminated TLS, which here it did not. HSTS is what
	# keeps that cookie off a plaintext request, and every request to this host
	# carries it.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8141 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy applies no
	# default request body limit, so a large attachment upload gets through.
	reverse_proxy 127.0.0.1:8141
}
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-siyuan, reload, and report what it objected to. Caddy requests the
certificate on the first request to the hostname and renews it on its own, so there is nothing to
schedule.

## 6. Firewall

Two ports open, both Caddy's. These 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 answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and 443/udp
is HTTP/3. 8141 stays closed because compose binds it to 127.0.0.1 and Caddy is the only thing
that speaks to it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule mentioning 8141 or 6806.

## 7. Start and verify

The kernel builds its index on the first start, so the loop below is doing real work, not waiting
on a socket.

```bash
cd /srv/siyuan
docker compose pull
docker compose up -d
for i in $(seq 1 30); do body=$(curl -sS https://<DOMAIN>/api/system/bootProgress || true); echo "$i $body"; echo "$body" | grep -q '"progress":100' && break; sleep 10; done
curl -sS https://<DOMAIN>/api/system/version; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS -A 'Mozilla/5.0' -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/check-auth | grep -o 'Unlock access'
```

Assert all five, and print what you received for each. The loop ends on a body containing
`"progress":100`. The version call prints `{"code":0,"msg":"","data":"3.7.3"}`, which is the pin
confirming itself. The plain request to the root prints `401`, the kernel's answer to anything
that is not a browser, and the security assert in this block. The same request with a browser
user agent prints `302` to the unlock screen, and the last command prints `Unlock access`, the
button on it. If any of the five misses, stop, run `docker compose logs --tail 40 siyuan`, and say
which earlier step is the likely cause: a container that exits within seconds is step 3, because
the kernel refuses to boot in a container with no access code and exits rather than serving an
open workspace; a `502` from Caddy with a running container is step 5. A running container is not
success.

The first screen at https://<DOMAIN> is that unlock page: a heading reading `workspace`, one
password box whose placeholder reads `Please enter the lock screen password`, and the
`Unlock access` button.

STOP: tell the user to read their code with
`sudo grep SIYUAN_ACCESS_AUTH_CODE /srv/siyuan/.env`, put it in their password manager, open
https://<DOMAIN>, paste it into that box, press `Unlock access`, and wait. Do not continue until
they confirm the editor has loaded. Tell them a few wrong answers add a captcha to that box, so a
paste that lost a character is worth checking before a third try.

## 8. First backup and restore

One archive: the whole workspace, the compose file, the env file and the live Caddy site block.
Take it now, before the user writes anything they would miss.

```bash
cd /srv/siyuan
docker compose stop
sudo tar -czf /srv/siyuan/backups/siyuan-$(date +%F).tar.gz -C /srv/siyuan workspace compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/siyuan/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is about ten seconds, and
the container is stopped on purpose, because the kernel holds a SQLite index open and a database
copied mid-write is not a backup.

A backup on the same disk as the data is not a backup. Run this one from the user's machine, not
the server:

```bash
mkdir -p ~/backups/siyuan
scp vps:/srv/siyuan/backups/*.tar.gz ~/backups/siyuan/
```

To restore: `docker compose down`, `sudo rm -rf /srv/siyuan/workspace`, recreate the three
directories as in step 2, untar the archive back into /srv/siyuan, put the Caddy block back if
that is what was lost, then `docker compose up -d`. Tell the user what is inside: every notebook
is a folder of `.sy` JSON files under `workspace/data`, and every image they paste is an ordinary
file in `workspace/data/assets`, so a single lost document can be pulled from the archive with
`tar -xzf` and a copy rather than a full restore.

## 9. Updating later

New versions are listed at https://github.com/siyuan-note/siyuan/releases. Take a backup first,
then edit the image line in /srv/siyuan/compose.yml to the new tag and its digest. The Docker Hub
tag keeps the leading `v`, so release `v3.7.4` is image tag `v3.7.4`.

```bash
cd /srv/siyuan
docker compose pull
docker compose up -d
docker compose logs --tail 30 siyuan
```

SiYuan migrates its own workspace on the way up and can rebuild the index while it does. Watch
that log until it settles, then re-run step 7's boot progress and version checks before calling
the update done.

## 10. What will probably go wrong

The first thing you will do after step 7 is curl the root URL to see whether it is alive, and it
will answer `401` with a JSON body saying `Auth failed [session]`. I read that as a broken proxy
and spent ten minutes in the Caddy config. Nothing was wrong. The kernel sends a redirect to the
unlock page only when the request carries a browser user agent, and answers everything else with
that 401, so a bare `curl` gets the JSON and a browser gets the screen. Look in a browser before
changing anything, and use `/api/system/bootProgress` for a machine-readable answer.

## 11. Out of scope

- Do not set `SIYUAN_ACCESS_AUTH_CODE_BYPASS`. It removes the only gate this install has, and
  upstream added it for people serving a workspace on loopback, not on a public hostname.
- Do not configure sync in the Settings screen. Every provider the kernel offers, S3, WebDAV and
  a plain local folder alike, checks for a paid SiYuan account and switches sync back off when
  that check fails.
- Do not install the desktop or mobile app and point it at this hostname. Upstream lists
  application connections as unsupported for the Docker deployment, and the browser is the client
  here.
- Do not run the kernel's other subcommands against this workspace while the server is up. One
  kernel serves a workspace at a time, and a second process on the same files is how an index
  and a note stop agreeing.
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 SiYuan 3.7.3 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.

Two things to know before step 1, because together they decide whether you want this at all.
SiYuan is the outliner shape: blocks, block references, two-way links and daily notes, edited in
a browser. The container serves that same application over HTTP, and upstream states plainly that
the Docker deployment does not accept desktop or mobile application connections and supports
browsers only. So every device you use opens the same workspace on this server, the way a hosted
graph works, and the SiYuan apps in the app stores are not part of that. Second: the sync feature
inside SiYuan is not how this install keeps your devices together, and you should not turn it on.
There is one workspace on one server, and the kernel gates every sync provider it offers, S3,
WebDAV and a plain local folder alike, behind a paid SiYuan account.

## 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 `1024` MB available, at least `5` 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,
and 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. An IP that is not your
server's usually means a proxying CDN sits in front of the record; turn that off for this
hostname, because the certificate would be issued to somebody else's edge and the editor's
WebSocket has to reach your box.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/siyuan /srv/siyuan/backups
sudo install -d -m 700 -o 1000 -g 1000 /srv/siyuan/workspace
ls -la /srv/siyuan
```

You should see: `backups` owned by you, and `workspace` at mode `drwx------` owned by uid `1000`,
which may print as a bare number if no account on the box has that id.

If you do not: leave `workspace` owned by 1000 on purpose. The image creates a user with that id
and runs the kernel as it, and it chowns this directory to that id on every start, so setting it
now matches what the container will do rather than fighting it. Everything SiYuan keeps lives
under `workspace`, and nothing is written outside /srv/siyuan.

## 3. Secrets

One secret: the lock screen code. It is the only thing between this hostname and every note on
it, so treat it the way you would treat a vault password. It is generated here, on the server,
straight into a file only you can read.

```bash
umask 077
cat > /srv/siyuan/.env <<EOF
SIYUAN_ACCESS_AUTH_CODE=$(openssl rand -hex 32)
EOF
chmod 600 /srv/siyuan/.env
umask 022
ls -l /srv/siyuan/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

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 in different shells. Run `chmod 600 /srv/siyuan/.env` and carry on.
If the file already existed from an earlier attempt, this block has now replaced the code, which
is fine before the container has ever run and confusing afterwards: the code the container is
using is whatever it read at start-up, so restart it after any change here.

Read it once with `sudo grep SIYUAN_ACCESS_AUTH_CODE /srv/siyuan/.env` and put it in your
password manager. There is no account behind it and no reset link: SiYuan has one workspace and
one gate, and whoever holds the code holds the notebook.

Do not paste that file, the code, or any command output containing it into this chat window. The
agent path never sees the value at all; this path will hand it to a third party unless you make a
point of not doing that.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/siyuan/compose.yml <<'EOF'
# SiYuan · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker deployment .. https://github.com/siyuan-note/siyuan/blob/v3.7.3/README.md
#   image entrypoint ... https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/entrypoint.sh
#   kernel http api .... https://github.com/siyuan-note/siyuan/blob/v3.7.3/docs/API.md
#   access gate ........ https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/model/session.go
#
# One service and one workspace directory. The `command:` line is not optional:
# from v3.7.0 the kernel is a subcommand tree and the entrypoint pulls
# --workspace out of the arguments, puts it back in front of whatever is left,
# and hands the rest to the kernel, so `serve` has to be written here.
#
# The lock screen code arrives as SIYUAN_ACCESS_AUTH_CODE out of the env file
# instead of on the command line, where every process listing inside the
# container would carry it. Upstream documents both spellings and says the
# command line wins when both are set, so only one is used here.
#
# PUID and PGID are the ids the entrypoint creates a user for and re-execs as,
# and it chowns the mounted workspace to them on every start. SIYUAN_LANG pins
# the interface language so the check in step 7 has one right answer; upstream
# states it is applied on every start-up and overrides the language chosen in
# Settings. Tag and digest read from Docker Hub on 2026-08-06; the image
# publishes amd64, arm64, armv7 and armv8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  siyuan:
    image: b3log/siyuan:v3.7.3@sha256:908faf8ec55d391d95244982c081edabbaec118552d01fc3dc189d098cc0ffc8
    container_name: siyuan
    restart: unless-stopped
    command: ["serve", "--workspace=/siyuan/workspace"]
    env_file: /srv/siyuan/.env
    environment:
      PUID: "1000"
      PGID: "1000"
      TZ: "UTC"
      SIYUAN_LANG: "en"
    volumes:
      # conf/, data/ and temp/ appear under here on the first start. Notebooks
      # are folders of .sy JSON files under data/, and anything pasted into a
      # note lands beside them in data/assets.
      - /srv/siyuan/workspace:/siyuan/workspace
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8141.
      - "127.0.0.1:8141:6806"
    healthcheck:
      # Registered without the auth middleware, so it answers whether or not
      # anyone has unlocked the workspace yet.
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:6806/api/system/version"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 60s
EOF
cd /srv/siyuan && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/siyuan/.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:
run `rm /srv/siyuan/compose.yml` and paste again in one go. There is no database container here,
and that is correct rather than something missing: the kernel keeps its index in SQLite inside
the workspace, and the notes are JSON files beside 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-siyuan
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# SiYuan · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/siyuan-note/siyuan/blob/v3.7.3/README.md 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. Upstream asks for
# a reverse proxy in front of 6806 that also carries the /ws WebSocket route,
# and asks you not to reach the app through a URL rewrite because the rewrite
# breaks its authentication. Caddy's reverse_proxy upgrades WebSocket
# connections on its own and rewrites nothing, so one directive covers both.

<DOMAIN> {
	# The editor bundle and the kernel's JSON responses compress well. Caddy's
	# default encode matcher covers text, JSON, JavaScript and SVG only, so an
	# image pasted into a note passes through untouched.
	encode zstd gzip

	# SiYuan marks its session cookie HttpOnly, and marks it Secure only when
	# the kernel itself terminated TLS, which here it did not. HSTS is what
	# keeps that cookie off a plaintext request, and every request to this host
	# carries it.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8141 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy applies no
	# default request body limit, so a large attachment upload gets through.
	reverse_proxy 127.0.0.1:8141
}
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-siyuan /etc/caddy/Caddyfile`, reload, and
paste again. The most common cause is a `<DOMAIN>` you replaced in one place and not the other.
Caddy requests the certificate on the first request to the hostname and renews it on its own, so
there is nothing to schedule.

## 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 `8141` or `6806`.

If you do not: delete anything for `8141` or `6806` with `sudo ufw delete allow 8141`. 8141 is
bound to 127.0.0.1 by the compose file, so nothing outside the box can reach it and no rule is
needed. 80/tcp answers the ACME challenge and redirects to HTTPS, 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

The kernel builds its index on the first start, so the loop below is doing real work, not waiting
on a socket. It can take a couple of minutes on a small box.

```bash
cd /srv/siyuan
docker compose pull
docker compose up -d
for i in $(seq 1 30); do body=$(curl -sS https://<DOMAIN>/api/system/bootProgress || true); echo "$i $body"; echo "$body" | grep -q '"progress":100' && break; sleep 10; done
curl -sS https://<DOMAIN>/api/system/version; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS -A 'Mozilla/5.0' -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/check-auth | grep -o 'Unlock access'
```

You should see, in order: the loop ending on a body containing `"progress":100`, then
`{"code":0,"msg":"","data":"3.7.3"}`, then `401`, then `302`, then `Unlock access`.

If you do not: the `401` is the one worth understanding, because it looks like a failure and is
not. The kernel redirects to the unlock page only when the request carries a browser user agent,
and answers everything else with that 401, so a bare `curl` gets JSON and a browser gets the
screen. Seeing it means the gate is closed, which is the point of step 3. A container that exits
within seconds is step 3 done wrong: the kernel refuses to boot inside a container with no access
code and exits rather than serving an open workspace, and `docker compose logs --tail 40 siyuan`
says so in one line. A `502` from Caddy with a running container is step 5. If the loop never
reaches `"progress":100`, give it another round before you touch anything, then read the same
log. A running container is not success.

The first screen at https://<DOMAIN> is that unlock page: a heading reading `workspace`, one
password box whose placeholder reads `Please enter the lock screen password`, and the
`Unlock access` button. Open it now, paste in the code you saved in step 3, and press
`Unlock access`. A few wrong answers add a captcha to the box, so a paste that lost a character
is worth checking before a third try.

You should see, after unlocking: the editor, with a notebook list down the left side and an empty
document area.

## 8. First backup and restore

One archive: the whole workspace, the compose file, the env file and the live Caddy site block.
Take it now, before you write anything you would miss.

```bash
cd /srv/siyuan
docker compose stop
sudo tar -czf /srv/siyuan/backups/siyuan-$(date +%F).tar.gz -C /srv/siyuan workspace compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/siyuan/backups/
```

You should see: one file, a few megabytes on a fresh install. Downtime is about ten seconds, and
the container is stopped on purpose, because the kernel holds a SQLite index open and a database
copied mid-write is not a backup.

If you do not: an archive of a few hundred bytes means `tar` found nothing, which usually means
the container has never successfully started and `workspace` is still empty. Go back to step 7.

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/siyuan
scp vps:/srv/siyuan/backups/*.tar.gz ~/backups/siyuan/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/siyuan/`.

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 an empty notebook:

```bash
cd /srv/siyuan
docker compose down
sudo rm -rf /srv/siyuan/workspace
sudo install -d -m 700 -o 1000 -g 1000 /srv/siyuan/workspace
sudo tar -xzf /srv/siyuan/backups/siyuan-$(date +%F).tar.gz -C /srv/siyuan workspace
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/api/system/bootProgress; echo
```

You should see: `"progress":100` again, and the same code still unlocking the same workspace in a
browser.

If you do not: check that the untar put `conf` and `data` back under /srv/siyuan/workspace. What
is in there is worth knowing before you need it: every notebook is a folder of `.sy` JSON files
under `workspace/data`, and every image you paste is an ordinary file in `workspace/data/assets`,
so a single lost document can be pulled from the archive with `tar -xzf` and a copy rather than a
full restore.

## 9. Updating later

New versions are listed at https://github.com/siyuan-note/siyuan/releases. Take a backup first,
then edit the `image:` line in /srv/siyuan/compose.yml to the new tag and its digest. The Docker
Hub tag keeps the leading `v`, so release `v3.7.4` is image tag `v3.7.4`.

```bash
cd /srv/siyuan
docker compose pull
docker compose up -d
docker compose logs --tail 30 siyuan
```

You should see: the boot banner, then index and workspace messages, 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
boot progress and version checks from step 7 before you call the update done, and open a real
note as well, because a kernel that reports `"progress":100` can still be rebuilding an index
that a search will miss.

## 10. What will probably go wrong

The first thing you will do after step 7 is curl the root URL to see whether it is alive, and it
will answer `401` with a JSON body saying `Auth failed [session]`. I read that as a broken proxy
and spent ten minutes in the Caddy config. Nothing was wrong. The kernel sends a redirect to the
unlock page only when the request carries a browser user agent, and answers everything else with
that 401, so a bare `curl` gets the JSON and a browser gets the screen. Look in a browser before
changing anything, and use `/api/system/bootProgress` for a machine-readable answer.

## 11. Out of scope

- Do not set `SIYUAN_ACCESS_AUTH_CODE_BYPASS`. It removes the only gate this install has, and
  upstream added it for people serving a workspace on loopback, not on a public hostname.
- Do not configure sync in the Settings screen. Every provider the kernel offers, S3, WebDAV and
  a plain local folder alike, checks for a paid SiYuan account and switches sync back off when
  that check fails.
- Do not install the desktop or mobile app and point it at this hostname. Upstream lists
  application connections as unsupported for the Docker deployment, and the browser is the client
  here.
- Do not run the kernel's other subcommands against this workspace while the server is up. One
  kernel serves a workspace at a time, and a second process on the same files is how an index
  and a note stop agreeing.

308 lines · 14,984 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

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 SiYuan 3.7.3 under ~/selfhost/siyuan, answering at http://localhost:8141.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
SiYuan is the outliner shape, blocks and block references and daily notes, and this install
puts it at http://localhost:8141, which means this computer and nowhere else. The phone they
would reach for at the moment worth writing down cannot open it, and neither can the SiYuan app
in the app stores: upstream states that the Docker deployment does not accept desktop or mobile
application connections and supports browsers only. What they get is a private notebook in one
browser, on one desk.

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 ~
id -u
```

`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, and `id -u` prints this account's user id,
which step 5 needs. SiYuan needs 1024 MB of RAM available and 5 GB free on the home disk, and the
image publishes amd64 and arm64. On macOS and Windows that memory figure is the host's, and
Docker Desktop's VM takes its allocation out of it. If available RAM is under 1024 MB or free
disk is under 5 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/siyuan/workspace ~/selfhost/siyuan/backups
ls -la ~/selfhost/siyuan
```

Assert: `ls -la` shows `workspace` and `backups`, both owned by the user. Everything SiYuan keeps
goes under `workspace`, in folders openable in Finder or Explorer: `conf`, `temp`, and `data`
with one directory per notebook plus the `assets` folder pasted images land in. No ownership fix
runs here; step 5 handles the one case that needs it.

## 4. Secrets

One secret: the lock screen code, the only thing between this workspace and anyone else who
reaches this keyboard. Generate it here, print it nowhere, and keep it out of your summary and
out of any log line.

```bash
umask 077
cat > ~/selfhost/siyuan/.env <<EOF
SIYUAN_ACCESS_AUTH_CODE=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/siyuan/.env
umask 022
ls -l ~/selfhost/siyuan/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same on
all three systems. Upstream documents this value as an `--accessAuthCode` flag and as this
environment variable; the file is used because a command line is readable in every process
listing inside the container.

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/siyuan/compose.yml <<'EOF'
# SiYuan · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker deployment .. https://github.com/siyuan-note/siyuan/blob/v3.7.3/README.md
#   image entrypoint ... https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/entrypoint.sh
#   kernel http api .... https://github.com/siyuan-note/siyuan/blob/v3.7.3/docs/API.md
#   access gate ........ https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/model/session.go
#
# One service, paths relative to ~/selfhost/siyuan/ so one file works on macOS,
# Linux and Windows. `command:` is not optional: from v3.7.0 the kernel is a
# subcommand tree and the entrypoint rewrites the argument list around
# --workspace. PUID and PGID are the ids it re-execs as, and it chowns the
# mounted workspace to them at every start, which on Linux reaches real files
# in your home directory: step 5 rewrites them when this account is not uid
# 1000. Tag and digest read from Docker Hub on 2026-08-06; the image publishes
# amd64, arm64, armv7 and armv8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  siyuan:
    image: b3log/siyuan:v3.7.3@sha256:908faf8ec55d391d95244982c081edabbaec118552d01fc3dc189d098cc0ffc8
    container_name: siyuan
    restart: unless-stopped
    command: ["serve", "--workspace=/siyuan/workspace"]
    env_file: ./.env
    environment:
      PUID: "1000"
      PGID: "1000"
      TZ: "UTC"
      SIYUAN_LANG: "en"
    volumes:
      # conf/, data/ and temp/ appear here on the first start. Notebooks are
      # folders of .sy JSON files under data/, pasted images in data/assets.
      - ./workspace:/siyuan/workspace
    ports:
      # Loopback only: no other device on the wifi can reach 8141.
      - "127.0.0.1:8141:6806"
    healthcheck:
      # No auth middleware on this route, so it answers before the unlock.
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:6806/api/system/version"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 60s
EOF
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" != "1000" ]; then
  sed -i "s/^      PUID: \"1000\"$/      PUID: \"$(id -u)\"/;s/^      PGID: \"1000\"$/      PGID: \"$(id -g)\"/" ~/selfhost/siyuan/compose.yml
fi
grep -E '^      P(U|G)ID:' ~/selfhost/siyuan/compose.yml
cd ~/selfhost/siyuan && docker compose config >/dev/null && echo "compose OK"
```

Assert: the grep prints two lines that, on Linux, match `id -u` and `id -g`; then `compose OK`.
The container chowns the workspace to `PUID:PGID` at every start, and on Linux that lands on the
user's own files, so an account that is not uid 1000 would lose them. It does nothing on macOS
or Windows.

## 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's crypto and clipboard work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8141 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the
wifi, not anyone on the internet. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/siyuan/compose.yml
```

Assert: two lines, `- "127.0.0.1:8141:6806"` and the health check, which runs inside the
container.

## 7. Start and verify

The kernel builds its index on the first start, so the loop below is doing real work.

```bash
cd ~/selfhost/siyuan
docker compose pull
docker compose up -d
for i in $(seq 1 30); do body=$(curl -sS http://localhost:8141/api/system/bootProgress || true); echo "$i $body"; echo "$body" | grep -q '"progress":100' && break; sleep 10; done
curl -sS http://localhost:8141/api/system/version; echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8141/
curl -sS -A 'Mozilla/5.0' -o /dev/null -w '%{http_code}\n' http://localhost:8141/
curl -sS http://localhost:8141/check-auth | grep -o 'Unlock access'
```

Assert all five, and print what you received for each. The loop ends on a body containing
`"progress":100`. The version call prints `{"code":0,"msg":"","data":"3.7.3"}`, the pin confirming
itself. The plain request to the root prints `401`, the kernel's answer to anything that is not a
browser, and the security assert in this block. The same request with a browser user agent prints
`302` to the unlock screen, and the last command prints `Unlock access`, the button on it. If any
of the five misses, stop, run `docker compose logs --tail 40 siyuan`, and name the likely cause:
a container that exits within seconds is step 4, because the kernel refuses to boot in a
container with no access code. If `port is already allocated` came back, find what holds 8141
with `lsof -nP -iTCP:8141 -sTCP:LISTEN` (`netstat -ano | findstr :8141` on Windows) and stop
until the user frees it. A running container is not success.

The first screen at http://localhost:8141 is that unlock page: a heading reading `workspace`, one
password box whose placeholder reads `Please enter the lock screen password`, and the
`Unlock access` button.

STOP: tell the user to read their code with
`grep SIYUAN_ACCESS_AUTH_CODE ~/selfhost/siyuan/.env`, put it in their password manager, open
http://localhost:8141, paste it into that box, press `Unlock access`, and wait. Do not continue
until they confirm the editor has loaded. Tell them a few wrong answers add a captcha to it.

## 8. First backup and restore

One archive: the whole workspace, the compose file and the env file.

```bash
cd ~/selfhost/siyuan
docker compose stop
tar -C ~/selfhost/siyuan -czf ~/selfhost/siyuan/backups/siyuan-$(date +%F).tar.gz workspace compose.yml .env
docker compose start
ls -lh ~/selfhost/siyuan/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is about ten seconds. The
container is stopped on purpose: the kernel holds a SQLite index open, and a database copied
mid-write is not a backup.

That archive sits 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 it there with `cp`. In Git Bash a Windows
drive is written `/d/Backups`, not `D:\Backups`; confirm the destination exists before copying.
Assert: the user confirms the filename is listed there. If they have nowhere to put it, say
plainly that this install has no backup.

To restore: `docker compose down`, `rm -rf ~/selfhost/siyuan/workspace`, untar the archive back
into ~/selfhost/siyuan, then `docker compose up -d` and unlock with the same code, which came out
of the archive in `.env`. Every notebook is a folder of `.sy` JSON files under `workspace/data`,
with pasted images in `workspace/data/assets`, so one lost document comes back with `tar -xzf`
and a copy. That is the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/siyuan-note/siyuan/releases. Take a backup first,
then edit the image line in ~/selfhost/siyuan/compose.yml to the new tag and its digest. The
Docker Hub tag keeps the leading `v`: release `v3.7.4` is image tag `v3.7.4`.

```bash
cd ~/selfhost/siyuan
docker compose pull
docker compose up -d
docker compose logs --tail 30 siyuan
```

SiYuan migrates its own workspace on the way up. Watch that log until it settles, then re-run
step 7's checks before calling the update done.

## 10. What will probably go wrong

I rebooted this machine, the browser restored the tab I had left open on http://localhost:8141,
and it came back a connection error. For a minute I believed a notebook was gone. It was
not: Docker Desktop had not started with the session, so nothing was listening on 8141, while
every note sat where it had been in ~/selfhost/siyuan/workspace/data. `restart: unless-stopped`
only acts once the Docker daemon is up. Turn on Docker Desktop's start-at-login setting, and
after a reboot run `cd ~/selfhost/siyuan && docker compose up -d` before concluding anything is
broken.

## 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 8141 to 0.0.0.0 so a phone on the wifi can reach it, and do not set
  `SIYUAN_ACCESS_AUTH_CODE_BYPASS`. The lock screen code is the only gate here: one exposes it to
  every network the user joins, the other removes it.
- Do not configure sync in the Settings screen. Every provider the kernel offers, S3, WebDAV and
  a plain local folder alike, checks for a paid SiYuan account first.
- Do not install the desktop or mobile app and point it at this address. Upstream lists
  application connections as unsupported for the Docker deployment.
compose.local.ymlthe services, pinned · local layout44 lines

authored from upstream docs, never pasted · 2,095 bytes

# SiYuan · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker deployment .. https://github.com/siyuan-note/siyuan/blob/v3.7.3/README.md
#   image entrypoint ... https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/entrypoint.sh
#   kernel http api .... https://github.com/siyuan-note/siyuan/blob/v3.7.3/docs/API.md
#   access gate ........ https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/model/session.go
#
# One service, paths relative to ~/selfhost/siyuan/ so one file works on macOS,
# Linux and Windows. `command:` is not optional: from v3.7.0 the kernel is a
# subcommand tree and the entrypoint rewrites the argument list around
# --workspace. PUID and PGID are the ids it re-execs as, and it chowns the
# mounted workspace to them at every start, which on Linux reaches real files
# in your home directory: step 5 rewrites them when this account is not uid
# 1000. Tag and digest read from Docker Hub on 2026-08-06; the image publishes
# amd64, arm64, armv7 and armv8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  siyuan:
    image: b3log/siyuan:v3.7.3@sha256:908faf8ec55d391d95244982c081edabbaec118552d01fc3dc189d098cc0ffc8
    container_name: siyuan
    restart: unless-stopped
    command: ["serve", "--workspace=/siyuan/workspace"]
    env_file: ./.env
    environment:
      PUID: "1000"
      PGID: "1000"
      TZ: "UTC"
      SIYUAN_LANG: "en"
    volumes:
      # conf/, data/ and temp/ appear here on the first start. Notebooks are
      # folders of .sy JSON files under data/, pasted images in data/assets.
      - ./workspace:/siyuan/workspace
    ports:
      # Loopback only: no other device on the wifi can reach 8141.
      - "127.0.0.1:8141:6806"
    healthcheck:
      # No auth middleware on this route, so it answers before the unlock.
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:6806/api/system/version"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 60s

agent-readable mirror: /self-host/roam-research.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, pinned54 lines

authored from upstream docs, never pasted · 2,663 bytes

# SiYuan · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker deployment .. https://github.com/siyuan-note/siyuan/blob/v3.7.3/README.md
#   image entrypoint ... https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/entrypoint.sh
#   kernel http api .... https://github.com/siyuan-note/siyuan/blob/v3.7.3/docs/API.md
#   access gate ........ https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/model/session.go
#
# One service and one workspace directory. The `command:` line is not optional:
# from v3.7.0 the kernel is a subcommand tree and the entrypoint pulls
# --workspace out of the arguments, puts it back in front of whatever is left,
# and hands the rest to the kernel, so `serve` has to be written here.
#
# The lock screen code arrives as SIYUAN_ACCESS_AUTH_CODE out of the env file
# instead of on the command line, where every process listing inside the
# container would carry it. Upstream documents both spellings and says the
# command line wins when both are set, so only one is used here.
#
# PUID and PGID are the ids the entrypoint creates a user for and re-execs as,
# and it chowns the mounted workspace to them on every start. SIYUAN_LANG pins
# the interface language so the check in step 7 has one right answer; upstream
# states it is applied on every start-up and overrides the language chosen in
# Settings. Tag and digest read from Docker Hub on 2026-08-06; the image
# publishes amd64, arm64, armv7 and armv8.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  siyuan:
    image: b3log/siyuan:v3.7.3@sha256:908faf8ec55d391d95244982c081edabbaec118552d01fc3dc189d098cc0ffc8
    container_name: siyuan
    restart: unless-stopped
    command: ["serve", "--workspace=/siyuan/workspace"]
    env_file: /srv/siyuan/.env
    environment:
      PUID: "1000"
      PGID: "1000"
      TZ: "UTC"
      SIYUAN_LANG: "en"
    volumes:
      # conf/, data/ and temp/ appear under here on the first start. Notebooks
      # are folders of .sy JSON files under data/, and anything pasted into a
      # note lands beside them in data/assets.
      - /srv/siyuan/workspace:/siyuan/workspace
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8141.
      - "127.0.0.1:8141:6806"
    healthcheck:
      # Registered without the auth middleware, so it answers whether or not
      # anyone has unlocked the workspace yet.
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:6806/api/system/version"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 60s
Caddyfilethe hostname and TLS36 lines

authored from upstream docs, never pasted · 1,577 bytes

# SiYuan · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/siyuan-note/siyuan/blob/v3.7.3/README.md 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. Upstream asks for
# a reverse proxy in front of 6806 that also carries the /ws WebSocket route,
# and asks you not to reach the app through a URL rewrite because the rewrite
# breaks its authentication. Caddy's reverse_proxy upgrades WebSocket
# connections on its own and rewrites nothing, so one directive covers both.

<DOMAIN> {
	# The editor bundle and the kernel's JSON responses compress well. Caddy's
	# default encode matcher covers text, JSON, JavaScript and SVG only, so an
	# image pasted into a note passes through untouched.
	encode zstd gzip

	# SiYuan marks its session cookie HttpOnly, and marks it Secure only when
	# the kernel itself terminated TLS, which here it did not. HSTS is what
	# keeps that cookie off a plaintext request, and every request to this host
	# carries it.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8141 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy applies no
	# default request body limit, so a large attachment upload gets through.
	reverse_proxy 127.0.0.1:8141
}
install.shthe same install, no agent158 lines

authored from upstream docs, never pasted · 7,216 bytes

#!/usr/bin/env bash
# SiYuan · 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=notes.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/siyuan-note/siyuan/blob/v3.7.3/README.md
#   https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/entrypoint.sh
#   https://github.com/siyuan-note/siyuan/blob/v3.7.3/docs/API.md
#   https://github.com/siyuan-note/siyuan/blob/v3.7.3/kernel/model/session.go
#
# One secret is generated here, on this machine: the lock screen code that gates
# the whole workspace. It goes into /srv/siyuan/.env with mode 600 and is never
# printed. There is no account behind it and no reset link.
#
# What this install is: the SiYuan application served over HTTP from one
# container, opened in a browser. Upstream states that the Docker deployment
# does not accept desktop or mobile application connections.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/siyuan}"
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. notes.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 1024 ] || die "only ${avail_mb} MB of RAM available; the kernel and its index want 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 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 ----------------------------------------------------
#
# The workspace belongs to uid 1000 because the image entrypoint creates a user
# with that id and re-execs the kernel as it, chowning this directory on every
# start.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 -o 1000 -g 1000 "$APP_DIR/workspace"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: the value is typed or pasted into a password box in a
# browser, and hex survives every clipboard on the way. Read it later with
#   sudo grep SIYUAN_ACCESS_AUTH_CODE /srv/siyuan/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		SIYUAN_ACCESS_AUTH_CODE=$(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-siyuan"
	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 neither 8141 nor 6806 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; 8141 and 6806 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 -------------------------------------------------------------
#
# The kernel builds its index on the first start, so this wait is doing real
# work rather than watching a socket.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/system/bootProgress to report 100"
for _ in $(seq 1 30); do
	boot="$(curl -sS "https://${DOMAIN_HOST}/api/system/bootProgress" || true)"
	printf '%s\n' "$boot" | grep -q '"progress":100' && break
	sleep 10
done
printf '%s\n' "${boot:-}" | grep -q '"progress":100' \
	|| die "boot progress never reached 100. Check: docker compose logs --tail 40 siyuan"

# The pin confirming itself. util.Ver carries no leading v.
curl -sS "https://${DOMAIN_HOST}/api/system/version" | grep -q '"data":"3.7.3"' \
	|| die "/api/system/version did not report 3.7.3. Check: docker compose logs --tail 40 siyuan"

# The gate. Upstream answers a non-browser request with 401 and redirects a
# browser to the unlock page, so both codes have to be what they are.
plain="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$plain" = "401" ] || die "the root URL answered ${plain}, not 401. The workspace may be ungated. Stop and investigate."

browser="$(curl -sS -A 'Mozilla/5.0' -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$browser" = "302" ] || die "a browser request to the root URL answered ${browser}, not 302"

curl -sS "https://${DOMAIN_HOST}/check-auth" | grep -q 'Unlock access' \
	|| die "the unlock page did not render. Check: docker compose logs --tail 40 siyuan"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/siyuan-${STAMP}.tar.gz" -C "$APP_DIR" workspace compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/siyuan-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	SiYuan is answering at https://${DOMAIN_HOST}

	  1. The first screen is the unlock page: one password box and an
	     "Unlock access" button. Your lock screen code is in $APP_DIR/.env,
	     mode 600. Read it with
	       sudo grep SIYUAN_ACCESS_AUTH_CODE $APP_DIR/.env
	     and put it in your password manager. It was not printed here, and
	     there is no account behind it and no reset link.
	  2. Open the site in a browser and unlock it. A bare curl of the root URL
	     answers 401 on purpose: the kernel redirects browsers to the unlock
	     page and refuses everything else.
	  3. The browser is the client. Upstream states that this deployment does
	     not accept desktop or mobile application connections, and the sync
	     settings inside the app are gated behind a paid SiYuan account, so
	     leave them alone. Every device opens this one workspace instead.
	  4. First backup written to $APP_DIR/backups: the workspace, the compose
	     file, the env file and the live Caddy block. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight.

DONE

What 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 Roam Research.

  • The browser is the client, and upstream says so in one line: this deployment does not accept desktop or mobile application connections. That is less of a loss than it sounds, because it is also how Roam works. Every device opens the same graph on your server and there is nothing to reconcile. What you give up is the offline app, so with no signal there is no capture, and the SiYuan apps in the app stores are for a different way of running this software.
  • Getting your graph in is the hard part, and this install does not solve it. Upstream lists Markdown import as unsupported in the Docker deployment, along with export to PDF, HTML and Word, so a Roam export cannot be dropped into the running server. The workable route is to import on a SiYuan desktop install and copy the resulting workspace folder onto the box, and that is an afternoon of its own rather than a step in the prompt.
  • Sync is a paid feature you will not be using. Every provider the app offers, its own cloud, S3, WebDAV and even a plain local folder, is gated behind a SiYuan account with either a subscription or the one-time PRO purchase. This install needs none of it, because one workspace on one server is the thing sync exists to fake, but the settings screen will show you buttons that quietly refuse.
  • One code, no account, no reset link. The lock screen code generated during install is the entire access control: there are no users, no roles and no way back in if it is lost with the .env file. Back up the archive somewhere that is not the server, and put the code in a password manager the same minute it is created.
  • It is AGPL-3.0 and open, and it is made by a small team, B3log, operating as Yunnan Liandi Technology Co., Ltd. in China. The activity is real: 45,000 stars, a stable release on 2026-07-21 and alpha builds most weeks. The paid membership funds it, and the free half, which is everything this install runs, is what they say it is.

Where this came from

“Does not support desktop and mobile application connections, only supports use on browsers”

  • Upstream lists three limitations for the Docker deployment: no desktop or mobile application connections, no export to PDF, HTML or Word, and no Markdown file import. source
  • The kernel refuses to boot inside a container when no access authorization code is set, printing that the parameter must be set and exiting with a security-risk code unless SIYUAN_ACCESS_AUTH_CODE_BYPASS is true. source
  • Every sync provider SiYuan offers, its own cloud, S3, WebDAV and a local directory, is checked against a paid account before a sync runs, and sync is switched off and the config saved when that check fails. source
  • The image entrypoint creates a user and group from PUID and PGID, chowns the mounted workspace to them on every start, and re-execs the kernel as that user with the workspace path it parsed. source
  • GET /api/system/version and GET /api/system/bootProgress are registered without the auth middleware, so both answer before anyone has unlocked the workspace, while every request to the app itself goes through it. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Roam Research?

    Not Roam Research 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 SiYuan. A block-referencing outliner with daily notes and two-way links, served from one container to whatever browser you open next. The install is one command: one container 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 10 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 Roam Research?

    SiYuan. A block-referencing outliner with daily notes and two-way links, served from one container to whatever browser you open next. The only one here that keeps the shape and the server. Blocks with real block references, two-way links, daily notes and an outliner that zooms into any block, served from one container so every device opens the same graph in a browser exactly the way the hosted product works. What you take on is stated by upstream rather than discovered later: this deployment is browser-only, so the SiYuan apps in the app stores are not part of it, Markdown import is unavailable here, and the sync settings inside the app belong to a paid SiYuan account you do not need and should not turn on. One lock screen code is the whole gate, and the graph is JSON files you can read on your own disk. Also evaluated and ranked below it: Trilium. The prompt on this page installs SiYuan only. SiYuan 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 Roam Research?

    1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. SiYuan itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Roam Research Pro, $15/mo — $180 a year.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the SiYuan install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run SiYuan 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 SiYuan 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: On your own computer this answers only at http://localhost:8141, so the phone you would reach for at the moment worth writing down cannot open it, and the graph is only ever as current as the last time you sat at this desk. 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-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.