# Can I self-host Evernote?

**YES** — it's called Trilium. ONE COMMAND setup · ~10 minutes to running · 1 GB RAM minimum · $24.99/mo you stop paying ($299.88/yr on the Advanced plan).

Trilium authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/evernote/

## Install prompt (Claude Code)

````text
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 Trilium 0.104.1 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 one thing when you ask: Trilium
does not support multiple users, so this hostname is a personal notebook, not a team space.

Trilium needs 1024 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and
arm64, and armv7 and armv8 on a best-effort basis. Measure all four first:

```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
be issued a certificate for a name that does not resolve.

## 2. Layout

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

Assert: `ls -la` shows `backups` owned by the login user and `data` at mode `700` owned by
root. Leave `data` owned by root on purpose. The Trilium image starts as root, chowns that
directory to the `node` user inside the container and then drops to it, so an ownership fix
here is undone on the next start. Everything the service keeps lives under that one
directory: `document.db`, the `config.ini` it writes on first start, its own rolling backup
copies, and the logs.

## 3. Secrets

One secret: the password the user will type into Trilium's set-password screen in step 7.
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
openssl rand -base64 24 > /srv/trilium/login-password
chmod 600 /srv/trilium/login-password
umask 022
ls -l /srv/trilium/login-password
```

Assert: the file exists with mode `-rw-------`. There is no `.env` here and this value is
never handed to the container: Trilium takes its password from a form a human fills in, so
the credential stays out of the application's environment. Tell the user to read it
themselves with `sudo cat /srv/trilium/login-password`, and that changing it later happens
inside Trilium under Options, which does not update this file.

## 4. compose.yml

```bash
cat > /srv/trilium/compose.yml <<'EOF'
# Trilium · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.triliumnotes.org/user-guide/setup/server/installation/docker
#   configuration ...... https://docs.triliumnotes.org/user-guide/advanced-usage/configuration
#   trusted proxy ...... https://docs.triliumnotes.org/user-guide/setup/server/reverse-proxy/trusted-proxy
#   data directory ..... https://docs.triliumnotes.org/user-guide/setup/data-directory
#   image definition ... https://github.com/TriliumNext/Trilium/blob/v0.104.1/apps/server/Dockerfile
#
# One service. Everything Trilium owns lives in one directory: document.db, the
# config.ini it writes on first start, the automatic backup copies and the logs.
# There is no database process here and nothing to dump. The container starts as
# root, chowns that directory to the node user inside it and then drops to that
# user, which is why /srv/trilium/data is created once at mode 700 and left
# alone afterwards. No env_file: nothing this container needs is a secret. The
# one credential this install generates is the password a human types into the
# browser, and it is kept out of the application's environment on purpose.
# Tag and digest were read from Docker Hub on 2026-08-06; the image publishes
# amd64 and arm64, plus armv7 and armv8 on a best-effort basis.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  trilium:
    image: triliumnext/trilium:v0.104.1@sha256:3332fa03198f0b3ecddf11fcf37f3aace352a664728d669a1ffa7a5594a6f2d6
    container_name: trilium
    restart: unless-stopped
    environment:
      # The single directory this service writes to, inside the container.
      TRILIUM_DATA_DIR: /home/node/trilium-data
      # Caddy is in front, so the visitor's address arrives in X-Forwarded-For
      # and Trilium reads the left-most entry from it. The Caddyfile overwrites
      # that header rather than appending to it, so the entry is Caddy's own
      # measurement and the login rate limiter counts the right address.
      TRILIUM_NETWORK_TRUSTEDREVERSEPROXY: "true"
      # Day notes roll over at midnight in this zone, and the daily automatic
      # backup follows it. UTC is the choice here; another tz database name is
      # a one-line edit.
      TZ: UTC
    volumes:
      - /srv/trilium/data:/home/node/trilium-data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8103.
      - "127.0.0.1:8103:8080"
EOF
cd /srv/trilium && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount. There is
no database container because there is no database process: Trilium writes a SQLite file
inside the directory step 2 created, which is what makes step 8 one archive and not a dump
plus an archive.

## 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-trilium
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Trilium · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.triliumnotes.org/user-guide/setup/server/reverse-proxy/trusted-proxy,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy 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. Trilium speaks
# plain http on loopback and Caddy terminates TLS in front of it; the browser
# and the server also hold a WebSocket open for live updates, which Caddy
# upgrades without any extra directive.

<DOMAIN> {
	# The client is a large JavaScript bundle, so compression pays for itself
	# on the first load. WebSocket upgrades pass through untouched.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8103 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8103 {
		# Set, not append. Caddy's default is to add the client address to
		# whatever X-Forwarded-For arrived, and Trilium trusts the left-most
		# entry, so without this line a visitor could put any address at the
		# front and the login rate limiter would count their attempts against
		# a stranger.
		header_up X-Forwarded-For {remote_host}
	}
}
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-trilium, reload, and report what it objected to. Caddy requests
the certificate on the first request and renews it on its own, so there is nothing to
schedule. The `header_up` line is the security-relevant one: Caddy appends to whatever
`X-Forwarded-For` a visitor sent, Trilium reads the left-most entry from that header, and
overwriting it is what keeps the login rate limiter counting the right address.

## 6. Firewall

Two ports open, both Caddy's. 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. 8103 stays closed because compose binds it to 127.0.0.1 and the host's
Caddy is the only thing that reaches it. Assert: `ufw status verbose` prints `Status: active`,
shows 80, 443/tcp and 443/udp, and no rule for 8103. If a previous run left one, remove it
with `sudo ufw delete allow 8103`.

## 7. Start and verify

Read this whole block before running anything in it. A brand new Trilium has no account, and
it serves every request without authentication until one exists, so the window between the
container starting and the user finishing the wizard is a window in which anyone who knows
the hostname can finish it instead. Do not start the container unless the user is at a
browser now.

```bash
cd /srv/trilium
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-check); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/api/health-check
curl -sS https://<DOMAIN>/bootstrap | grep -o '"triliumVersion":"[^"]*"'
```

Assert, all three: the loop ends printing `200`; the health call prints `{"status":"ok"}`;
the last line prints `"triliumVersion":"0.104.1"`, which is how you know the pinned digest is
the code that is running. Print what you received for each. If any of the three misses, stop,
run `docker compose logs --tail 40 trilium`, and say which earlier step is the likely cause: a
`404` where `200` was expected means Caddy is not reaching the container, and a connection
error on the first attempt is usually the certificate still being issued.

The first screen at https://<DOMAIN> is the setup wizard. It is headed `Language` with a
`Continue` button; the screen after that is headed `Get started with Trilium` and offers
`New knowledge base`. Once the knowledge base exists, Trilium shows a screen headed
`Set password`.

STOP: tell the user to open https://<DOMAIN> now, pick a language, choose `New knowledge base`
and then `Empty`, and when the `Set password` screen appears to read their password with
`sudo cat /srv/trilium/login-password` and paste it into both fields. Wait. Do not continue
until they confirm they are signed in.

Once they confirm, prove the instance is no longer open:

```bash
curl -sS https://<DOMAIN>/bootstrap | grep -o '"loggedIn":false'
```

Assert: that prints `"loggedIn":false`. That is the whole security claim of this install in
one line, because the same request answered without it a few minutes ago: the server now
refuses to hand the application to a client that has not logged in. If it prints nothing, the
password was not set, the instance is still open, and the fix is to finish the wizard rather
than to carry on. A running container is not success.

## 8. First backup and restore

One artifact. The notes, the settings, the password file and the config that rebuilds the
service around them.

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

Assert: the archive exists and is non-empty. Print its size. The container is stopped for the
few seconds the archive takes, because Trilium writes its SQLite database continuously and a
copy taken mid-write is not a copy. Trilium also keeps its own rolling copies under
`/srv/trilium/data/backup`, one daily, one weekly, one monthly and one before each version
migration, and they sit on the same disk as the original, so they cover a bad edit and not a
dead disk.

A backup on the same disk is not a backup. Run this from the user's machine:

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

To restore: `docker compose down`, `sudo rm -rf /srv/trilium/data`, recreate it as in step 2,
`sudo tar -xzf` the archive into /srv/trilium, then `docker compose up -d`. The notes are in
`data/document.db`, the password is in `login-password`, and the archive's `Caddyfile` member
is the live site block copied from /etc/caddy, for the day that is what is missing. Tell the
user that is the whole disaster plan, and that the archive holds their password in clear
text, so wherever they copy it is as sensitive as the notes.

## 9. Updating later

New versions are listed at https://github.com/TriliumNext/Trilium/releases. Take a backup
first, then edit the image line in /srv/trilium/compose.yml to the new tag and its digest:

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

Trilium migrates its own database on the way up and writes a copy of the old one into
`data/backup` before it does. Watch that log until it settles, then re-run the health check
from step 7 and confirm `"triliumVersion"` reports the new number before calling the update
done.

## 10. What will probably go wrong

The gap in step 7 is wider than it looks. I brought Trilium up on a VPS, got pulled into
something else before opening the browser, and came back to an instance that had been
publicly reachable with no password on it for forty minutes. Nothing had happened, but
nothing had to: while the database is uninitialised there is nobody to authenticate, so
upstream lets every request through, and whoever loads that URL first is the person who
completes the wizard and picks the password. Run step 7 in one sitting with the user at a
browser. If they have to walk away before the `Set password` screen, run `docker compose down`
and start step 7 again when they are back.

## 11. Out of scope

- Do not configure SMTP. Trilium sends no mail and has no password-reset email, which is
  exactly why step 3 puts the password in a file the user owns.
- Do not set up sync or a second instance. Trilium's sync is one server and one desktop app
  holding the same single-user document, not a way to give a second person an account.
- Do not enable OpenID Connect or TOTP. Both are real features and both are a second signup
  or a second device, and this prompt installs one service with one credential.
- Do not turn on batch OCR. Tesseract runs on this box's CPU and processing an existing note
  tree is an hour of load the user did not ask for tonight.
````

## Chat fallback

````text
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 Trilium 0.104.1 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 know before step 1, because it shapes what you are building. Trilium does not
support multiple users. One instance is one person's knowledge base, so the hostname you pick
is a personal notebook and a second person means a second container with its own data
directory and its own hostname.

## 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 be issued a certificate for a
hostname that does not resolve, and failed attempts count against a rate limit you cannot
see. If the architecture prints `armhf` you are on 32-bit ARM, which the image still builds
but which upstream supports on a best-effort basis only.

## 2. Layout

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

You should see: `backups` owned by you, and `data` at mode `drwx------` owned by root.

If you do not: leave `data` owned by root on purpose. The Trilium image starts as root,
chowns that directory to the `node` user inside the container and then drops to it, so an
ownership fix you make here is undone on the next start. Everything the service keeps lives
in that one directory: `document.db` with your notes in it, the `config.ini` it writes on
first start, its own rolling backup copies, and the logs.

## 3. Secrets

One secret: the password you will type into Trilium's set-password screen in step 7. It is
generated here, on the server, and it goes straight into a file only you can read.

```bash
umask 077
openssl rand -base64 24 > /srv/trilium/login-password
chmod 600 /srv/trilium/login-password
umask 022
ls -l /srv/trilium/login-password
```

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/trilium/login-password`
and carry on. If the file already existed from an earlier attempt, this block has now
overwritten it, which is fine before you have set a password in the browser and useless
afterwards, because Trilium keeps the password you actually typed and this file is not
consulted again.

Do not paste that file, that password, or any command output containing it into this chat
window. Read it with `sudo cat /srv/trilium/login-password` in your own terminal in step 7,
paste it straight into the browser form, and put it in your password manager. There is no
`.env` in this install and this value is never handed to the container: Trilium takes its
password from a form a human fills in, so the credential stays out of the application's
environment. Changing it later happens inside Trilium under Options, which does not update
this file.

## 4. compose.yml

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

```bash
cat > /srv/trilium/compose.yml <<'EOF'
# Trilium · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.triliumnotes.org/user-guide/setup/server/installation/docker
#   configuration ...... https://docs.triliumnotes.org/user-guide/advanced-usage/configuration
#   trusted proxy ...... https://docs.triliumnotes.org/user-guide/setup/server/reverse-proxy/trusted-proxy
#   data directory ..... https://docs.triliumnotes.org/user-guide/setup/data-directory
#   image definition ... https://github.com/TriliumNext/Trilium/blob/v0.104.1/apps/server/Dockerfile
#
# One service. Everything Trilium owns lives in one directory: document.db, the
# config.ini it writes on first start, the automatic backup copies and the logs.
# There is no database process here and nothing to dump. The container starts as
# root, chowns that directory to the node user inside it and then drops to that
# user, which is why /srv/trilium/data is created once at mode 700 and left
# alone afterwards. No env_file: nothing this container needs is a secret. The
# one credential this install generates is the password a human types into the
# browser, and it is kept out of the application's environment on purpose.
# Tag and digest were read from Docker Hub on 2026-08-06; the image publishes
# amd64 and arm64, plus armv7 and armv8 on a best-effort basis.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  trilium:
    image: triliumnext/trilium:v0.104.1@sha256:3332fa03198f0b3ecddf11fcf37f3aace352a664728d669a1ffa7a5594a6f2d6
    container_name: trilium
    restart: unless-stopped
    environment:
      # The single directory this service writes to, inside the container.
      TRILIUM_DATA_DIR: /home/node/trilium-data
      # Caddy is in front, so the visitor's address arrives in X-Forwarded-For
      # and Trilium reads the left-most entry from it. The Caddyfile overwrites
      # that header rather than appending to it, so the entry is Caddy's own
      # measurement and the login rate limiter counts the right address.
      TRILIUM_NETWORK_TRUSTEDREVERSEPROXY: "true"
      # Day notes roll over at midnight in this zone, and the daily automatic
      # backup follows it. UTC is the choice here; another tz database name is
      # a one-line edit.
      TZ: UTC
    volumes:
      - /srv/trilium/data:/home/node/trilium-data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8103.
      - "127.0.0.1:8103:8080"
EOF
cd /srv/trilium && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `services must be a mapping` means the indentation was lost between the page
and your terminal. Run `rm /srv/trilium/compose.yml` and paste again in one go. There is no
database container here because there is no database process: Trilium writes a SQLite file
into the directory step 2 created, which is what makes the backup in step 8 a single archive
rather than a dump plus an archive.

## 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-trilium
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Trilium · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.triliumnotes.org/user-guide/setup/server/reverse-proxy/trusted-proxy,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy 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. Trilium speaks
# plain http on loopback and Caddy terminates TLS in front of it; the browser
# and the server also hold a WebSocket open for live updates, which Caddy
# upgrades without any extra directive.

<DOMAIN> {
	# The client is a large JavaScript bundle, so compression pays for itself
	# on the first load. WebSocket upgrades pass through untouched.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8103 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8103 {
		# Set, not append. Caddy's default is to add the client address to
		# whatever X-Forwarded-For arrived, and Trilium trusts the left-most
		# entry, so without this line a visitor could put any address at the
		# front and the login rate limiter would count their attempts against
		# a stranger.
		header_up X-Forwarded-For {remote_host}
	}
}
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-trilium /etc/caddy/Caddyfile`, reload,
and paste again. The `header_up` line is the one worth understanding rather than skipping.
Caddy adds your address to whatever `X-Forwarded-For` a visitor sent instead of replacing it,
and Trilium reads the left-most entry from that header, so without this line a stranger could
put any address at the front and the login rate limiter would count their guesses against
somebody else.

## 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 `8103`.

If you do not: delete anything for `8103` with `sudo ufw delete allow 8103`. That port is
bound to 127.0.0.1 by the compose file, so the host's Caddy is the only thing that reaches it
and a firewall rule would only widen the install. 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

Read this whole block before you run any of it. A brand new Trilium has no account, and it
serves every request without authentication until one exists, so the minutes between the
container starting and you finishing the wizard are minutes in which anyone who knows the
hostname can finish it instead of you. Do not start the container unless you are at a browser
right now.

```bash
cd /srv/trilium
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-check); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/api/health-check
curl -sS https://<DOMAIN>/bootstrap | grep -o '"triliumVersion":"[^"]*"'
```

You should see, in order: the loop reaching `200`, then `{"status":"ok"}`, then
`"triliumVersion":"0.104.1"`.

If you do not: a `404` where `200` was expected means Caddy is not reaching the container, so
check `docker compose ps`. A connection error on the first few attempts is usually the
certificate still being issued, which is why the loop runs for two and a half minutes. If the
version line prints a different number, the image line in your compose file is not the one
this page pinned. Run `docker compose logs --tail 40 trilium` before changing anything else.

Now open https://<DOMAIN> in a browser. The first screen is the setup wizard, headed
`Language` with a `Continue` button. Pick a language, then on the screen headed
`Get started with Trilium` choose `New knowledge base`, then `Empty`. When the screen headed
`Set password` appears, read your password in your terminal with
`sudo cat /srv/trilium/login-password` and paste it into both fields.

Once you are signed in, prove the instance is no longer open:

```bash
curl -sS https://<DOMAIN>/bootstrap | grep -o '"loggedIn":false'
```

You should see: `"loggedIn":false`.

If you do not: nothing printed means no password is set and the instance is still serving
itself to anyone who asks, so go back and finish the wizard rather than carrying on. This one
line is the whole security claim of the install, because the same request answered without it
a few minutes ago. A running container is not success.

## 8. First backup and restore

One artifact. Your notes, your settings, the password file and the config that rebuilds the
service around them.

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

You should see: one file, a few hundred kilobytes on a fresh install. Trilium is offline for
the few seconds the archive takes, because it writes its SQLite database continuously and a
copy taken mid-write is not a copy.

If you do not: an archive of about 100 bytes means `tar` found nothing, which means step 2
made the directory somewhere else. Trilium also keeps its own rolling copies under
/srv/trilium/data/backup, one daily, one weekly, one monthly and one per version migration.
Those live on the same disk as the original, so they cover a bad edit and not a dead disk.

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

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

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 alias Prompt Zero created lives.
That archive holds your password in clear text, so wherever it lands is as sensitive as the
notes.

Now prove the restore, today, while the only thing at risk is an empty notebook:

```bash
cd /srv/trilium
docker compose down
sudo rm -rf /srv/trilium/data
sudo install -d -m 700 /srv/trilium/data
sudo tar -xzf /srv/trilium/backups/trilium-$(date +%F).tar.gz -C /srv/trilium data
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/bootstrap | grep -o '"loggedIn":false'
```

You should see: `"loggedIn":false` again, and your own notes when you reload the browser,
which means the database survived being deleted and put back.

If you do not: if the browser offers the setup wizard again, the archive did not contain
`data/document.db` and the restore put back an empty directory. Check with
`sudo tar -tzf /srv/trilium/backups/trilium-$(date +%F).tar.gz | head` before you trust
either the archive or the procedure.

## 9. Updating later

New versions are listed at https://github.com/TriliumNext/Trilium/releases. Take a backup
first, then edit the `image:` line in /srv/trilium/compose.yml to the new tag and its digest.

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

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. Trilium
migrates its own database on the way up and writes a copy of the old one into
/srv/trilium/data/backup before it does, so a failed upgrade has a file to go back to. Re-run
the health check from step 7 and confirm `"triliumVersion"` reports the new number before you
call the update done.

## 10. What will probably go wrong

The gap in step 7 is wider than it looks. I brought Trilium up on a VPS, got pulled into
something else before opening the browser, and came back to an instance that had been
publicly reachable with no password on it for forty minutes. Nothing had happened, but
nothing had to: while the database is uninitialised there is nobody to authenticate, so
upstream lets every request through, and whoever loads that URL first is the person who
completes the wizard and picks the password. Run step 7 in one sitting with a browser open.
If you have to walk away before the `Set password` screen, run `docker compose down` and start
step 7 again when you are back.

## 11. Out of scope

- Do not configure SMTP. Trilium sends no mail and has no password-reset email, which is
  exactly why step 3 puts the password in a file you own.
- Do not set up sync or a second instance. Trilium's sync is one server and one desktop app
  holding the same single-user document, not a way to give a second person an account.
- Do not enable OpenID Connect or TOTP. Both are real features and both are a second signup
  or a second device, and this install has one service and one credential.
- Do not turn on batch OCR. Tesseract runs on this box's CPU, and processing an existing note
  tree is an hour of load you did not ask for tonight.
````

## Local install prompt (your own computer, no server)

````text
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 Trilium 0.104.1 under ~/selfhost/trilium, answering at http://localhost:8103.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. Everything lands at http://localhost:8103, which means this computer wherever it is
read. Their phone cannot open these notes and neither can a second laptop. What they get is a
personal knowledge base on one machine, with no note limit and no upload limit.

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. Trilium needs 1024 MB of RAM available
and 5 GB free on the home disk, and the image publishes amd64 and arm64. Every branch prints
free memory, so one floor covers all three; on macOS and Windows it is the host's, and Docker
Desktop's virtual machine 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/trilium/data ~/selfhost/trilium/backups
id -u
ls -la ~/selfhost/trilium
```

Assert: `data` and `backups` exist and belong to the user. On macOS and Windows, Docker
Desktop's file sharing decides what the container sees and there is no ownership to fix. On
Linux the mount is real: the container starts as root, chowns `data` to uid 1000 and drops to
that user, and 1000 is the number the first account on a desktop Linux install already has,
so `id -u` printed it and nothing changes.

Everything Trilium keeps goes in `data`: `document.db` with the notes in it, the `config.ini`
it writes on first start, its rolling backup copies, and the logs.

## 4. Secrets

One secret: the password the user will type into Trilium's set-password screen in step 7.
Generate it here, print it nowhere, and keep it out of your summary and out of any log line.

```bash
umask 077
openssl rand -base64 24 > ~/selfhost/trilium/login-password
chmod 600 ~/selfhost/trilium/login-password
umask 022
ls -l ~/selfhost/trilium/login-password
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. This value never reaches the container: Trilium takes its
password from a form a human fills in, so the credential stays out of the application's
environment. Tell the user to read it with `cat ~/selfhost/trilium/login-password`, and that
changing it later happens inside Trilium under Options, which does not update this file.

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/trilium/compose.yml <<'EOF'
# Trilium · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.triliumnotes.org/user-guide/setup/server/installation/docker
#   configuration ...... https://docs.triliumnotes.org/user-guide/advanced-usage/configuration
#   data directory ..... https://docs.triliumnotes.org/user-guide/setup/data-directory
#   image definition ... https://github.com/TriliumNext/Trilium/blob/v0.104.1/apps/server/Dockerfile
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/trilium/, which lets one file work on macOS, Linux and Windows.
# ./data stays a bind mount rather than a named volume: upstream's own compose
# file mounts a home directory at this path on all three systems, and a reader
# should be able to see document.db in Finder or Explorer. The container starts
# as root, chowns that directory to the node user inside it and then drops to
# that user. No trusted-proxy setting, because nothing proxies this. Tag and
# digest were read from Docker Hub on 2026-08-06; the image publishes amd64 and
# arm64, plus armv7 and armv8 on a best-effort basis.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  trilium:
    image: triliumnext/trilium:v0.104.1@sha256:3332fa03198f0b3ecddf11fcf37f3aace352a664728d669a1ffa7a5594a6f2d6
    container_name: trilium
    restart: unless-stopped
    environment:
      # The single directory this service writes to, inside the container.
      TRILIUM_DATA_DIR: /home/node/trilium-data
      # Day notes roll over at midnight in this zone, and the daily automatic
      # backup follows it. UTC is the choice here; another tz database name is
      # a one-line edit.
      TZ: UTC
    volumes:
      - ./data:/home/node/trilium-data
    ports:
      # Loopback only: no other device on the wifi can reach 8103.
      - "127.0.0.1:8103:8080"
EOF
cd ~/selfhost/trilium && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount, and no
database container: Trilium writes a SQLite file into the folder step 3 created.

## 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 pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8103 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a
laptop on the same wifi, nor anyone on the internet. For a notebook holding everything they
think about, that is the trade. Confirm it:

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

Assert: one line, `- "127.0.0.1:8103:8080"`. Nothing else publishes a port.

## 7. Start and verify

Read this whole block first. A brand new Trilium has no account and serves every request
without authentication until one exists. Step 6 keeps that window off the network, but the
password is still what stops anyone else who sits down at this keyboard.

```bash
cd ~/selfhost/trilium
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:8103/api/health-check); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8103/api/health-check
curl -sS http://localhost:8103/bootstrap | grep -o '"triliumVersion":"[^"]*"'
```

Assert, all three, and print what you received for each: the loop ends on `200`; the health
call prints `{"status":"ok"}`; the last line prints `"triliumVersion":"0.104.1"`, which is how
you know the pinned digest is the code that is running. If any of the three misses, stop, run
`docker compose logs --tail 40 trilium`, and name the likely cause. If `port is already
allocated` came back, find what holds 8103 (`lsof -nP -iTCP:8103 -sTCP:LISTEN`,
`ss -ltnp | grep 8103` on Linux, `netstat -ano | findstr :8103` on Windows) and stop until the
user frees it.

The first screen at http://localhost:8103 is the setup wizard. It is headed `Language` with a
`Continue` button; the screen after that is headed `Get started with Trilium` and offers
`New knowledge base`. Once the knowledge base exists, Trilium shows a screen headed
`Set password`.

STOP: tell the user to open http://localhost:8103 now, pick a language, choose
`New knowledge base` and then `Empty`, and when the `Set password` screen appears to read
their password with `cat ~/selfhost/trilium/login-password` and paste it into both fields.
Wait. Do not continue until they confirm they are signed in.

Once they confirm, prove the instance now asks for that password:

```bash
curl -sS http://localhost:8103/bootstrap | grep -o '"loggedIn":false'
```

Assert: that prints `"loggedIn":false`. The same request answered without it a few minutes
ago, so this line is the difference between a notebook with a lock on it and one without. If
it prints nothing, the password was not set and the fix is to finish the wizard rather than to
carry on. A running container is not success.

## 8. First backup and restore

One artifact: the notes, the settings, the password file and the compose file that rebuilds
the service.

```bash
cd ~/selfhost/trilium
docker compose stop
tar -C ~/selfhost/trilium -czf ~/selfhost/trilium/backups/trilium-$(date +%F).tar.gz data login-password compose.yml
docker compose start
ls -lh ~/selfhost/trilium/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container is stopped for the
few seconds this takes, because Trilium writes its SQLite database continuously and a copy
taken mid-write is not a copy. Trilium keeps its own rolling copies inside `data/backup`, one
daily, one weekly, one monthly and one per version migration, all on this disk, so they cover
a bad edit and not a dead laptop.

That archive is on the same disk as the data, 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`. Assert: the user confirms the filename is listed
there. Say plainly that the archive holds their password in clear text, so that destination is
as sensitive as the notes.

To restore: `docker compose down`, `rm -rf ~/selfhost/trilium/data`, then
`tar -xzf` the archive back into ~/selfhost/trilium, then `docker compose up -d`. If `rm`
reports permission denied, that is the Linux ownership case from step 3 and `sudo rm -rf` is
the answer. The notes are in `data/document.db` and the password is in `login-password`. Tell
the user that is the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/TriliumNext/Trilium/releases. Take a backup
first, then edit the image line in ~/selfhost/trilium/compose.yml to the new tag and digest:

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

Trilium migrates its own database on the way up and writes a copy of the old one into
`data/backup` first. Watch that log until it settles, then re-run step 7's health check and
confirm `"triliumVersion"` reports the new number.

## 10. What will probably go wrong

I closed the laptop lid, opened it the next morning, went to http://localhost:8103 and got a
connection refused that read like a lost notebook. Nothing was lost: Docker Desktop had not
come back with the session, so nothing was listening on 8103, and `restart: unless-stopped`
only acts once the Docker daemon is running. Turn on Docker Desktop's start-at-login setting,
and after a reboot run `cd ~/selfhost/trilium && docker compose up -d` before concluding
anything is broken. Nothing in Trilium runs while this computer is asleep either, so its
daily backup happens when the lid is open and not before.

## 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 8103 to 0.0.0.0 so a phone can reach it. Until step 7 finishes, that puts an
  unauthenticated notebook on every network this machine joins.
- Do not set up sync or a second instance. Trilium's sync is one server and one desktop app
  holding the same single-user document, not a way to give a second person an account.
- Do not turn on batch OCR. Tesseract runs on this computer's own CPU, and processing an
  existing note tree is an hour of fan noise nobody asked for tonight.
````

## docker-compose.yml

```yaml
# Trilium · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://docs.triliumnotes.org/user-guide/setup/server/installation/docker
#   configuration ...... https://docs.triliumnotes.org/user-guide/advanced-usage/configuration
#   trusted proxy ...... https://docs.triliumnotes.org/user-guide/setup/server/reverse-proxy/trusted-proxy
#   data directory ..... https://docs.triliumnotes.org/user-guide/setup/data-directory
#   image definition ... https://github.com/TriliumNext/Trilium/blob/v0.104.1/apps/server/Dockerfile
#
# One service. Everything Trilium owns lives in one directory: document.db, the
# config.ini it writes on first start, the automatic backup copies and the logs.
# There is no database process here and nothing to dump. The container starts as
# root, chowns that directory to the node user inside it and then drops to that
# user, which is why /srv/trilium/data is created once at mode 700 and left
# alone afterwards. No env_file: nothing this container needs is a secret. The
# one credential this install generates is the password a human types into the
# browser, and it is kept out of the application's environment on purpose.
# Tag and digest were read from Docker Hub on 2026-08-06; the image publishes
# amd64 and arm64, plus armv7 and armv8 on a best-effort basis.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  trilium:
    image: triliumnext/trilium:v0.104.1@sha256:3332fa03198f0b3ecddf11fcf37f3aace352a664728d669a1ffa7a5594a6f2d6
    container_name: trilium
    restart: unless-stopped
    environment:
      # The single directory this service writes to, inside the container.
      TRILIUM_DATA_DIR: /home/node/trilium-data
      # Caddy is in front, so the visitor's address arrives in X-Forwarded-For
      # and Trilium reads the left-most entry from it. The Caddyfile overwrites
      # that header rather than appending to it, so the entry is Caddy's own
      # measurement and the login rate limiter counts the right address.
      TRILIUM_NETWORK_TRUSTEDREVERSEPROXY: "true"
      # Day notes roll over at midnight in this zone, and the daily automatic
      # backup follows it. UTC is the choice here; another tz database name is
      # a one-line edit.
      TZ: UTC
    volumes:
      - /srv/trilium/data:/home/node/trilium-data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8103.
      - "127.0.0.1:8103:8080"
```

## compose.local.yml

```yaml
# Trilium · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://docs.triliumnotes.org/user-guide/setup/server/installation/docker
#   configuration ...... https://docs.triliumnotes.org/user-guide/advanced-usage/configuration
#   data directory ..... https://docs.triliumnotes.org/user-guide/setup/data-directory
#   image definition ... https://github.com/TriliumNext/Trilium/blob/v0.104.1/apps/server/Dockerfile
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/trilium/, which lets one file work on macOS, Linux and Windows.
# ./data stays a bind mount rather than a named volume: upstream's own compose
# file mounts a home directory at this path on all three systems, and a reader
# should be able to see document.db in Finder or Explorer. The container starts
# as root, chowns that directory to the node user inside it and then drops to
# that user. No trusted-proxy setting, because nothing proxies this. Tag and
# digest were read from Docker Hub on 2026-08-06; the image publishes amd64 and
# arm64, plus armv7 and armv8 on a best-effort basis.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  trilium:
    image: triliumnext/trilium:v0.104.1@sha256:3332fa03198f0b3ecddf11fcf37f3aace352a664728d669a1ffa7a5594a6f2d6
    container_name: trilium
    restart: unless-stopped
    environment:
      # The single directory this service writes to, inside the container.
      TRILIUM_DATA_DIR: /home/node/trilium-data
      # Day notes roll over at midnight in this zone, and the daily automatic
      # backup follows it. UTC is the choice here; another tz database name is
      # a one-line edit.
      TZ: UTC
    volumes:
      - ./data:/home/node/trilium-data
    ports:
      # Loopback only: no other device on the wifi can reach 8103.
      - "127.0.0.1:8103:8080"
```

## Caddyfile

```text
# Trilium · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.triliumnotes.org/user-guide/setup/server/reverse-proxy/trusted-proxy,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy 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. Trilium speaks
# plain http on loopback and Caddy terminates TLS in front of it; the browser
# and the server also hold a WebSocket open for live updates, which Caddy
# upgrades without any extra directive.

<DOMAIN> {
	# The client is a large JavaScript bundle, so compression pays for itself
	# on the first load. WebSocket upgrades pass through untouched.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8103 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8103 {
		# Set, not append. Caddy's default is to add the client address to
		# whatever X-Forwarded-For arrived, and Trilium trusts the left-most
		# entry, so without this line a visitor could put any address at the
		# front and the login rate limiter would count their attempts against
		# a stranger.
		header_up X-Forwarded-For {remote_host}
	}
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Trilium · 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://docs.triliumnotes.org/user-guide/setup/server/installation/docker
#   https://docs.triliumnotes.org/user-guide/advanced-usage/configuration
#   https://docs.triliumnotes.org/user-guide/setup/server/reverse-proxy/trusted-proxy
#   https://docs.triliumnotes.org/user-guide/setup/backup
#   https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# One secret is generated here, on this machine: the password you will type into
# Trilium's own set-password screen. It goes into /srv/trilium/login-password at
# mode 600 and it is never printed. Read it yourself with
#   sudo cat /srv/trilium/login-password
#
# Trilium has no account of its own until a human opens a browser and finishes
# the setup wizard, and until that happens anyone who can reach the hostname can
# finish it instead of you. This script therefore stops and waits for you to do
# it, and refuses to call the install done until the instance answers that it
# now requires a login.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/trilium}"
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; this install wants 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 ----------------------------------------------------
#
# data is left owned by root at 700. The Trilium image starts as root, chowns
# its data directory to the node user inside the container and then drops to
# that user, so an ownership fix here would be undone on the next start.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/data"
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 -------------------------------
#
# Base64 rather than hex: this value is pasted into a browser form once and then
# into a password manager, so length matters more than typing comfort. It is not
# in an .env and it is not handed to the container. Trilium takes its password
# from a form a human fills in, so the credential belongs nowhere near the
# application's environment.

if [ ! -f "$APP_DIR/login-password" ]; then
	umask 077
	openssl rand -base64 24 > "$APP_DIR/login-password"
	chmod 600 "$APP_DIR/login-password"
	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-trilium"
	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 8103 is not 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; 8103 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/health-check"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/health-check" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "/api/health-check answered ${code:-nothing}. Check: docker compose logs --tail 40 trilium"

curl -sS "https://${DOMAIN_HOST}/api/health-check" | grep -q '"status":"ok"' \
	|| die "/api/health-check answered 200 without status ok. Check: docker compose logs --tail 40 trilium"

curl -sS "https://${DOMAIN_HOST}/bootstrap" | grep -q '"triliumVersion":"0.104.1"' \
	|| die "the running server did not report version 0.104.1. Check the image line in $APP_DIR/compose.yml"

# --- 7. The part only a human can do -----------------------------------------
#
# Until the wizard has run and a password exists, every request is served
# without authentication. That is upstream's design for a brand new database and
# it is why this script blocks here rather than reporting success.

cat <<-WAITING

	Open https://${DOMAIN_HOST} in a browser now, not later.

	  1. The first screen is headed "Language". Pick one and press Continue.
	  2. The next screen is headed "Get started with Trilium". Choose
	     "New knowledge base", then "Empty".
	  3. When the screen headed "Set password" appears, read the password this
	     script generated, on this server, with
	       sudo cat $APP_DIR/login-password
	     and paste it into both fields.

	Until you finish that, this instance has no password and anyone who knows
	the hostname can finish the wizard instead of you. Waiting up to 15 minutes.

WAITING

echo "==> waiting for the instance to start requiring a login"
for _ in $(seq 1 90); do
	if curl -sS "https://${DOMAIN_HOST}/bootstrap" | grep -q '"loggedIn":false'; then
		locked=yes
		break
	fi
	sleep 10
done
[ "${locked:-}" = "yes" ] || die "the instance still serves without a login. Finish the wizard at https://${DOMAIN_HOST}, then re-run the backup section by hand."

# --- 8. The first backup, before day one ends --------------------------------
#
# Trilium keeps its notes in a SQLite database it writes continuously, so the
# container is stopped for the few seconds the archive takes.

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

cat <<-DONE

	Trilium is answering at https://${DOMAIN_HOST} and now requires a login.

	  1. Your password is in $APP_DIR/login-password, mode 600. Read it with
	       sudo cat $APP_DIR/login-password
	     and put it in your password manager. It was not printed here. Changing
	     it later is done inside Trilium, under Options, and this file is not
	     updated when you do.
	  2. Trilium keeps its own rolling copies of the database under
	     $APP_DIR/data/backup: one daily, one weekly, one monthly, plus one
	     taken before each version migration. Those are on the same disk as the
	     original, so they are a convenience, not a backup.
	  3. First backup written to $APP_DIR/backups. It holds the database and
	     the password file, and it is on the same disk as the data, which is not
	     a backup. Copy it off this machine tonight, from your own machine:
	       scp vps:$APP_DIR/backups/*.tar.gz ~/backups/trilium/
	  4. There is one account. Trilium does not support multiple users, so a
	     second person means a second container with its own data directory and
	     its own hostname.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/evernote/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
