Can I self-host Evernote?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called Trilium. It takes one prompt, a 1024 MB VPS, and about 10 minutes. That is $24.99 a month you stop paying Evernote — $299.88 a year on the Advanced plan.
Why people pay for Evernote
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.
Evernote sells the search box more than the editor. Twenty years of clippings, receipts, scanned pages and half-finished lists, findable from any device without you ever having decided where to put them, on a service you do not keep running. The free tier stopped being somewhere to live: on the comparison table today it holds 50 notes in 1 notebook on 1 device, which is a demo of the search box rather than a place to keep an archive, so the ladder above it is where the actual product starts.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | The comparison table read on 2026-08-06 lists 50 notes, 1 notebook, 1 synced device and 1 GB of storage. |
| Starter | $14.99/mo | $99 a year on the annual plan, which the page's own pricing data presents as $8.25 a month. A weekly option at $3.99 is offered as well. The table lists 1,000 notes, 20 notebooks, 3 synced devices and 5 GB. |
| Advancedthe plan this page prices against | $24.99/mo | $249.99 a year on the annual plan, which the page's own pricing data presents as $20.83 a month. A weekly option at $6.99 is offered as well. The table lists unlimited notes, notebooks and synced devices, with a footnote about safeguard limits. |
| Enterprise | quote only | Quote only. Every row of the comparison table reads Custom and the button says Choose rather than showing a price. The feature list mentions 20 GB monthly uploads plus 2 GB per user. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Trilium
One project, named before the prompt, so you know what you are about to install.
A personal knowledge base with a note tree, relations and scripting, keeping every note in one SQLite file you can copy.
The closest thing to Evernote's shape that one container can honestly be. A tree of notes rather than a flat list, attachments and images kept with them, search across all of it, a browser clipper that exists as a real Firefox and Chrome extension, and an ENEX importer that reads your notebooks back one file at a time with formatting, tables and attachments intact. What you take on is the part Evernote never asks you about: it is one person's notebook with no accounts to add, it serves itself to anyone until you finish the setup wizard and set a password, and the copy of the database that leaves the machine is a copy you scheduled.
The swap
You'd run
Trilium
ONE COMMAND · ~10 min to running · 1024 MB RAM
Evernote Advanced · 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 Trilium: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
314 lines · 14,733 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install 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.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 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.304 lines · 14,985 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install 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.compose.local.ymlthe services, pinned · local layout36 lines
# 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"agent-readable mirror: /self-host/evernote.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, pinned43 lines
# 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"Caddyfilethe hostname and TLS37 lines
# 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.shthe same install, no agent185 lines
#!/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.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Evernote.
- One account, and upstream says so in one sentence: Trilium does not support multiple users. Sharing a notebook with somebody means handing over your password or running a second container on a second hostname. There are no invites, no permissions and no shared-with-me.
- A new instance has no lock on it. Trilium serves every request without authentication until a human opens a browser and finishes the setup wizard, so the install and the first login belong in the same sitting. The install asserts that the instance refuses anonymous access before it reports success.
- You own the backups. Trilium keeps four rolling copies of the database and writes all four into the same directory as the live one, so they cover a bad edit and not a dead disk. The copy that leaves the machine is the one you schedule.
- Getting out of Evernote is an import, not a migration. Trilium reads ENEX, one file per notebook, 250 MB at a time, and it carries formatting, tables, images and attachments across. Notebook stacks, reminders, saved searches and the links between separate imports are not part of that.
- The Evernote features that do not come across: no email-into-notebook address, no shared notebooks, no handwriting search. The web clipper does exist, as a Firefox and Chrome extension. Text extraction exists since 0.103, but you turn it on yourself, Tesseract runs on your own CPU, and a scanned PDF stays unsearchable because PDFs are text extraction only.
Where this came from
“Avoid using the "latest" tag, as it may automatically upgrade your instance to a new minor version, potentially disrupting sync setups or causing other issues.”
- Trilium does not support multiple users, so two people means two server instances, each with its own port and its own data directory. source
- The official images are published on Docker Hub for AMD64, ARMv7 and ARM64/v8, the server listens on 8080, and everything it keeps lives in the directory mounted at /home/node/trilium-data. source
- Behind a reverse proxy, trustedReverseProxy makes Trilium read the visitor's address from the left-most entry of X-Forwarded-For, which is what its authentication and rate limiting count. source
- Trilium's own backup is at most four rolling copies of the database, daily, weekly, monthly and one before each migration, written into the same data directory as the live file. source
- Trilium imports Evernote ENEX files, one file per notebook, with a 250 MB limit per import and every non-image resource landing as a note attachment. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Evernote?
Not Evernote 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 Trilium. A personal knowledge base with a note tree, relations and scripting, keeping every note in one SQLite file you can copy. 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 Evernote?
Trilium. A personal knowledge base with a note tree, relations and scripting, keeping every note in one SQLite file you can copy. The closest thing to Evernote's shape that one container can honestly be. A tree of notes rather than a flat list, attachments and images kept with them, search across all of it, a browser clipper that exists as a real Firefox and Chrome extension, and an ENEX importer that reads your notebooks back one file at a time with formatting, tables and attachments intact. What you take on is the part Evernote never asks you about: it is one person's notebook with no accounts to add, it serves itself to anyone until you finish the setup wizard and set a password, and the copy of the database that leaves the machine is a copy you scheduled. Trilium 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 Evernote?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Trilium itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Evernote Advanced, $24.99/mo — $299.88 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 Trilium install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Trilium 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 Trilium 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: Everything answers at http://localhost:8103, which means this computer and nowhere else, so your phone cannot open these notes and a Trilium desktop app on another machine has nothing here to sync with. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.