The Trilium server ships as one tarball with its own Node.js runtime inside. There is nothing to compile and no npm to manage. Unpack it, give it a system user and a systemd unit, and put Nginx in front. The whole job takes about ten minutes, and systemd reports about 55 MB of memory for the idle service.
This guide installs the Trilium Notes server on Ubuntu and Debian two ways: from the official release tarball under systemd, and with Docker Compose. Both methods detect the latest release with curl, so the commands keep working as new versions ship. After that come the Nginx reverse proxy with a Let’s Encrypt certificate, the setup wizard, sync and ETAPI for desktop clients and MCP, nightly SQLite backups with a tested restore, and upgrades. We tested on Ubuntu 26.04, Ubuntu 24.04 and Debian 13 in October 2026 against Trilium Notes 0.106.0. The tarball install ran on all three, Docker on 24.04 and Debian 13, and Nginx, Certbot, backup and restore on 26.04.
Prerequisites
- A server running Ubuntu 26.04, Ubuntu 24.04 or Debian 13, with sudo access. Our lab VMs had 2 vCPUs and 4 GB of RAM, which is far more than Trilium needs.
- About 300 MB of disk for the application, plus room for your notes and attachments.
- A domain name with an A record pointing at the server, and port 80 reachable for the Let’s Encrypt HTTP-01 challenge. Any DNS provider works.
ca-certificates,curl,xz-utilsandsqlite3. The backup script needs sqlite3.
Install the small tools first:
sudo apt update
sudo apt install -y ca-certificates curl xz-utils sqlite3
Set the two values that repeat through the guide. Replace them with your own domain and email:
export DOMAIN="notes.example.com"
export EMAIL="[email protected]"
Choose an install method
Both methods run the same server code. The tarball is lighter and needs nothing else on the host. Docker suits a box that already runs containers. The numbers below were measured on our VMs:
| Item | Release tarball + systemd | Docker Compose |
|---|---|---|
| Download | 89 MB tar.xz | 513 MB image |
| Installed size | 293 MB in /opt/trilium | 513 MB image |
| Data directory | /var/lib/trilium | ./trilium-data next to compose.yaml |
| Runs as | dedicated trilium system user | root entrypoint, drops to node (UID 1000) |
| Start to healthy | 0.84 to 0.88 s | healthy at the 25 s check |
| Upgrade | swap /opt/trilium, restart | new tag in .env, docker compose up -d |
Pick one and skip the other section. The Nginx, backup and upgrade steps cover both.
Install the Trilium server from the release tarball
Trilium publishes a separate server build next to the desktop packages. The file to fetch is the one with Server in its name. The desktop .deb is an Electron app, and it is the wrong tool for a headless box.
Ask the GitHub API for the latest release tag and keep it in a variable:
TRILIUM_VERSION=$(curl -s https://api.github.com/repos/TriliumNext/Trilium/releases/latest | grep '"tag_name"' | cut -d '"' -f 4)
echo "$TRILIUM_VERSION"
The variable holds the tag with its leading v:
v0.106.0
Download the server tarball and unpack it. On an ARM64 server, replace x64 with arm64 in both lines:
curl -LO "https://github.com/TriliumNext/Trilium/releases/download/${TRILIUM_VERSION}/TriliumNotes-Server-${TRILIUM_VERSION}-linux-x64.tar.xz"
tar -xJf "TriliumNotes-Server-${TRILIUM_VERSION}-linux-x64.tar.xz"
This catches people out: the archive name keeps the v, but the directory inside it drops it. The tarball above unpacks to TriliumNotes-Server-0.106.0-linux-x64. The ${TRILIUM_VERSION#v} expansion strips the prefix when moving it into place:
sudo mv "TriliumNotes-Server-${TRILIUM_VERSION#v}-linux-x64" /opt/trilium
Create a system account with no login shell and a private data directory for it:
sudo useradd --system --home-dir /var/lib/trilium --shell /usr/sbin/nologin trilium
sudo install -d -o trilium -g trilium -m 750 /var/lib/trilium
Create the systemd unit
Open a new unit file:
sudo vim /etc/systemd/system/trilium.service
Add the following configuration:
[Unit]
Description=Trilium Notes server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=trilium
Group=trilium
WorkingDirectory=/opt/trilium
Environment=TRILIUM_DATA_DIR=/var/lib/trilium
Environment=TRILIUM_NETWORK_HOST=127.0.0.1
Environment=TRILIUM_NETWORK_PORT=8080
Environment=TRILIUM_NETWORK_TRUSTEDREVERSEPROXY=127.0.0.1
ExecStart=/opt/trilium/trilium.sh
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target
Every TRILIUM_* variable maps to a key in config.ini, and environment variables win over the file. TRILIUM_NETWORK_HOST=127.0.0.1 keeps the app off the public interface, so only Nginx can reach it. TRILIUM_NETWORK_TRUSTEDREVERSEPROXY tells Trilium to read the client address from X-Forwarded-For, which its login rate limiting depends on. WorkingDirectory is not optional, because trilium.sh calls ./node/bin/node with a relative path.
Reload systemd and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now trilium
Check the service and hit the health endpoint:
systemctl status trilium --no-pager
curl -s http://127.0.0.1:8080/api/health-check
The unit shows active (running) with about 55 MB of memory, and the health check returns {"status":"ok"}:

The journal shows the startup banner, including the database and sync protocol versions. Those two numbers matter later for upgrades and desktop clients:
sudo journalctl -u trilium --no-pager -o cat | grep -E "Versions|Data dir|Trusted|Listening"
The four lines confirm the release, the data directory, the trusted proxy and the listening port. A separate banner in the same journal says the database is not initialised yet, which the setup wizard fixes:
📦 Versions: app=0.106.0 db=240 sync=39 clipper=1.0
📂 Data dir: /var/lib/trilium
Trusted reverse proxy: 127.0.0.1
Listening on port 8080
Skip to the Nginx section if you are not using Docker.
Run Trilium with Docker Compose
Install Docker Engine and the Compose plugin from the Docker repository. The same commands work on Ubuntu and Debian, because the repository path comes from /etc/os-release. Docker publishes packages for Ubuntu 26.04, Ubuntu 24.04 and Debian 13. Our guides for Docker CE on Ubuntu 26.04 and Docker on Debian cover the details:
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
printf 'Types: deb\nURIs: https://download.docker.com/linux/%s\nSuites: %s\nComponents: stable\nSigned-By: /etc/apt/keyrings/docker.asc\n' "$(. /etc/os-release && echo "$ID")" "$(. /etc/os-release && echo "$VERSION_CODENAME")" | sudo tee /etc/apt/sources.list.d/docker.sources
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
Log out and back in so the docker group applies. Then create a project directory and the Compose file:
mkdir -p ~/trilium && cd ~/trilium
vim compose.yaml
Paste this service definition:
services:
trilium:
image: triliumnext/trilium:${TRILIUM_VERSION}
container_name: trilium
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
- TRILIUM_NETWORK_TRUSTEDREVERSEPROXY=uniquelocal
volumes:
- ./trilium-data:/home/node/trilium-data
- /etc/localtime:/etc/localtime:ro
The image tag is a variable, and Compose reads it from a .env file in the same directory. The upstream Compose file warns that latest may cause unintended updates, and an unplanned upgrade on the server can leave desktop clients on a different sync protocol. Write the current release into .env instead, so the tag only moves when you move it:
echo "TRILIUM_VERSION=$(curl -s https://api.github.com/repos/TriliumNext/Trilium/releases/latest | grep '"tag_name"' | cut -d '"' -f 4)" > .env
docker compose up -d
A few other choices in the file are deliberate. The port binds to 127.0.0.1 because ports published by Docker bypass UFW, and Nginx sits in front anyway. Requests reach the container from the Docker bridge, which lives in 172.16.0.0/12, so the trusted proxy is uniquelocal instead of 127.0.0.1. The upstream Compose file also mounts /etc/timezone. We dropped that line, for the reason covered in the errors section below.
Give the container about 20 seconds, then check its status:
cat .env
docker compose ps
docker compose logs trilium | grep -E "Versions|Trusted|Listening"
On Debian 13 the container reports healthy, running the tag that curl detected:

The entrypoint chowns trilium-data to UID 1000. Back up that directory, not the image.
Put Nginx and Let’s Encrypt in front of Trilium
Both install methods leave Trilium on 127.0.0.1:8080. Install Nginx and Certbot:
sudo apt install -y nginx certbot python3-certbot-nginx
Create the virtual host:
sudo vim /etc/nginx/sites-available/trilium
Add this server block. Certbot adds the TLS lines in the next step:
upstream trilium {
server 127.0.0.1:8080;
keepalive 2;
}
server {
listen 80;
listen [::]:80;
server_name notes.example.com;
client_max_body_size 0;
location / {
proxy_pass http://trilium;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90;
}
}
The Upgrade and Connection headers carry the WebSocket that pushes live changes to open browser tabs. client_max_body_size 0 removes the 1 MB Nginx default, which otherwise rejects any larger attachment or import with a 413 error. Put your domain into the file, enable the site, drop the default site and test the config:
sudo sed -i "s/notes.example.com/${DOMAIN}/" /etc/nginx/sites-available/trilium
sudo ln -s /etc/nginx/sites-available/trilium /etc/nginx/sites-enabled/trilium
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
The syntax check should pass:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Reload Nginx, then request the certificate. Certbot proves control of the domain over port 80, writes the certificate and rewrites the server block for HTTPS. --redirect adds a permanent redirect from HTTP:
sudo systemctl reload nginx
sudo certbot --nginx -d "${DOMAIN}" --non-interactive --agree-tos --redirect -m "${EMAIL}"
Our lab sits on a private network with no public port 80, so we ran this exact command against Pebble, the ACME test server Let’s Encrypt maintains, by adding its --server URL and CA bundle. The HTTP-01 challenge, the Nginx edits and the renewal timer behave the same way against the production CA. Afterwards Certbot has moved the site to 443 and added a redirect block on port 80. Confirm both:
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" "http://${DOMAIN}/"
curl -s -o /dev/null -w "%{http_code}\n" "https://${DOMAIN}/"
systemctl list-timers certbot.timer --no-pager
HTTP answers with a 301 to HTTPS, HTTPS answers 200, and certbot.timer is scheduled to handle renewals:
301 https://notes.example.com/
200
If UFW is active, open the web ports. Port 8080 stays closed, because Trilium listens only on loopback. The UFW guide for Ubuntu 26.04 covers the rest of the firewall:
sudo ufw allow 'Nginx Full'
Server with no public port 80: use DNS-01
A homelab box behind NAT cannot answer the HTTP-01 challenge. The DNS-01 challenge proves ownership with a TXT record instead, through the API of your DNS provider. Install the plugin for your provider:
| DNS provider | Debian/Ubuntu package | Certbot flag |
|---|---|---|
| Cloudflare | python3-certbot-dns-cloudflare | –dns-cloudflare |
| Route 53 | python3-certbot-dns-route53 | –dns-route53 |
| DigitalOcean | python3-certbot-dns-digitalocean | –dns-digitalocean |
| Google Cloud DNS | python3-certbot-dns-google | –dns-google |
| Linode | python3-certbot-dns-linode | –dns-linode |
| OVH | python3-certbot-dns-ovh | –dns-ovh |
| BIND or any RFC 2136 server | python3-certbot-dns-rfc2136 | –dns-rfc2136 |
The worked example uses Cloudflare. Substitute the plugin for your provider and credentials file if you are on something else. Create an API token with Zone DNS Edit permission for the zone, then store it in a root-only file:
sudo apt install -y python3-certbot-dns-cloudflare
sudo install -d -m 700 /root/.secrets
echo "dns_cloudflare_api_token = your-token-here" | sudo tee /root/.secrets/cloudflare.ini > /dev/null
sudo chmod 600 /root/.secrets/cloudflare.ini
Issue the certificate over DNS and let the Nginx installer wire it into the server block in one run. Keeping both in one command matters: Certbot stores the installer in the renewal config, so each renewal reloads Nginx with the new certificate. This block was not run in our lab:
sudo certbot run -a dns-cloudflare -i nginx --dns-cloudflare-credentials /root/.secrets/cloudflare.ini -d "${DOMAIN}" --redirect --non-interactive --agree-tos -m "${EMAIL}"
Run the Trilium setup wizard
Do this right away. Until a password is set, the first person to reach the setup page owns the instance. Open https://notes.example.com, pick a language, and the wizard offers four starting points:

Choose New knowledge base for a fresh server. Connect a desktop app is for the case where your notes already live in a desktop install. That pulls the whole tree from the desktop into the server. The next screen offers demo content or an empty base. The demo notes are worth a look on a first install, and you can delete the whole subtree later. The server then asks for a password:

The password protects the web login and is the key for protected notes. Trilium has no recovery for it. Resetting it from Options permanently locks you out of every protected note. After login you land in the note tree, served over HTTPS through Nginx:

The demo database contains 577 notes and 1,411 attributes. It is a quick way to see the attribute, template and collection features before you build your own structure.
Connect desktop clients and MCP
Desktop clients sync against this server. On a new desktop install, the setup wizard offers Connect to an existing server. Enter https://notes.example.com and the server password, and the desktop pulls the full tree. The reverse direction also exists. If your notes already live in a desktop app, leave the server uninitialised, then enter its address under Options > Sync on the desktop and click Test now. The desktop then pushes its database up and sets the server password. That is how our Obsidian vs Logseq vs Trilium test seeded a server with a 10,000-note database in 25 seconds.
Keep the server and the desktop clients on the same release. The startup banner prints a sync= protocol number, which was 39 on the release we tested. Trilium compares this number on every sync, and a client on a different protocol cannot sync until both sides run the same release.
Scripts and AI agents use ETAPI, the Trilium REST API, instead of the browser session. The server log line MCP server registered at /mcp (ETAPI token required) means this build also serves a Model Context Protocol endpoint for Claude and other MCP clients. Create a token under Options > ETAPI:

The token is shown once. Store it like a password, because it grants full read and write access to every unprotected note.
Back up the Trilium database
Everything Trilium stores lives in one SQLite file, document.db, inside the data directory. The built-in backups write daily, weekly and monthly copies into a backup folder next to it. Backup now under Options > Backup takes an extra copy on demand:

Those copies sit on the same disk as the database, so they do not survive a dead disk or a deleted VM. Add a nightly copy that you can ship elsewhere. Create the script:
sudo vim /usr/local/bin/trilium-backup.sh
Add this content. It uses the SQLite online backup, so the copy is consistent while Trilium is running. umask 077 keeps the copies readable by root only, since they hold every note in clear text. It then checks the integrity of the copy, compresses it and keeps 14 days:
#!/bin/bash
set -euo pipefail
umask 077
DEST=/var/backups/trilium
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$DEST"
sqlite3 /var/lib/trilium/document.db ".backup '${DEST}/document-${STAMP}.db'"
sqlite3 "${DEST}/document-${STAMP}.db" "PRAGMA integrity_check;" | grep -qx ok
gzip "${DEST}/document-${STAMP}.db"
find "$DEST" -name 'document-*.db.gz' -mtime +14 -delete
For the Docker install, change the source path to the absolute path of trilium-data/document.db, for example /home/josphat/trilium/trilium-data/document.db. The script runs as root, so ~ would point at /root. Make the script executable:
sudo chmod 750 /usr/local/bin/trilium-backup.sh
A systemd timer runs it every night. Create the service unit first:
sudo vim /etc/systemd/system/trilium-backup.service
It is a oneshot that calls the script:
[Unit]
Description=Back up the Trilium database
[Service]
Type=oneshot
ExecStart=/usr/local/bin/trilium-backup.sh
Then the timer:
sudo vim /etc/systemd/system/trilium-backup.timer
Persistent=true runs a missed backup at the next boot if the server was off at 02:30:
[Unit]
Description=Nightly Trilium database backup
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
Enable the timer and run one backup now to prove it works:
sudo systemctl daemon-reload
sudo systemctl enable --now trilium-backup.timer
sudo systemctl start trilium-backup.service
sudo ls -lh /var/backups/trilium/
The demo database compressed to 1.3 MB:
total 1.3M
-rw------- 1 root root 1.3M Oct 1 22:05 document-20261001-220538.db.gz
Copy /var/backups/trilium off the box with whatever you already run, such as restic, rclone or an rsync job.
Restore from a backup
An untested backup proves nothing, so we restored this one. Stop the service, remove the WAL and shared-memory files so SQLite does not replay stale pages, and put the newest copy in place:
sudo systemctl stop trilium
sudo rm -f /var/lib/trilium/document.db-wal /var/lib/trilium/document.db-shm
BACKUP=$(sudo sh -c 'ls -t /var/backups/trilium/document-*.db.gz | head -1')
sudo sh -c "gunzip -c $BACKUP > /var/lib/trilium/document.db"
sudo chown trilium:trilium /var/lib/trilium/document.db
sudo systemctl start trilium
Trilium runs a consistency check on every start. After the restore, the journal reported the same 577 notes as before the backup, with no errors:
Table counts: notes: 577, revisions: 0, attachments: 19, branches: 592, attributes: 1411, etapi_tokens: 0, blobs: 140
All consistency checks passed with no errors detected (took 15ms)
Upgrade Trilium
An upgrade is the install again: the same curl detection picks up the newest tag. For the tarball install, download the new build, stop the service, archive the data directory, and swap the application directory. The previous build stays in /opt/trilium.old for a quick rollback:
TRILIUM_VERSION=$(curl -s https://api.github.com/repos/TriliumNext/Trilium/releases/latest | grep '"tag_name"' | cut -d '"' -f 4)
curl -LO "https://github.com/TriliumNext/Trilium/releases/download/${TRILIUM_VERSION}/TriliumNotes-Server-${TRILIUM_VERSION}-linux-x64.tar.xz"
tar -xJf "TriliumNotes-Server-${TRILIUM_VERSION}-linux-x64.tar.xz"
sudo systemctl stop trilium
sudo tar -czf "/var/backups/trilium-data-$(date +%F).tar.gz" -C /var/lib trilium
sudo rm -rf /opt/trilium.old
sudo mv /opt/trilium /opt/trilium.old
sudo mv "TriliumNotes-Server-${TRILIUM_VERSION#v}-linux-x64" /opt/trilium
sudo systemctl start trilium
We ran this on Debian 13 to move a server from 0.105.0 to 0.106.0. The service came back on the new release and passed its consistency check. The database version stayed at 240, so that release changed no schema:
All consistency checks passed with no errors detected (took 9ms)
📦 Versions: app=0.106.0 db=240 sync=39 clipper=1.0
Listening on port 8080
For Docker, rewrite .env with the same echo command used during install, then pull and recreate the container:
cd ~/trilium
echo "TRILIUM_VERSION=$(curl -s https://api.github.com/repos/TriliumNext/Trilium/releases/latest | grep '"tag_name"' | cut -d '"' -f 4)" > .env
docker compose pull
docker compose up -d
Rolling back is safe only while the database version is unchanged. When a release migrates the schema, the older build refuses to open the upgraded database, as the next section shows. In that case, restore the data archive taken before the upgrade together with the old application directory.
Errors we hit and the fixes
These came up while testing on the three systems. The messages are copied from the journal or the shell.
exec: ./node/bin/node: not found
The service exits with status 127 straight after starting:
/opt/trilium/trilium.sh: 2: exec: ./node/bin/node: not found
trilium.service: Main process exited, code=exited, status=127/n/a
The unit has no WorkingDirectory. trilium.sh is a two-line wrapper that runs exec ./node/bin/node main.mjs relative to the current directory, and systemd starts services in /. Add WorkingDirectory=/opt/trilium, then run sudo systemctl daemon-reload and restart the service. The older version of this guide shipped exactly that broken unit.
The version of the database (240) is newer than what the application expects (234)
This is what a downgrade across a schema change looks like. We pointed an older server build at a copy of a database created by the current release:

Trilium migrates the database forward on the first start of a newer release, and there is no migration back. Either run the newer release again, or restore the data archive you took before the upgrade. Test major upgrades on a copy of /var/lib/trilium first if the instance matters.
ls: cannot access ‘TriliumNotes-Server-v0.106.0-linux-x64’: No such file or directory
The release asset is named with a v prefix, but the directory inside the archive has none. Use ${TRILIUM_VERSION#v} when you refer to the extracted directory, as the install step does.
A stray /etc/timezone directory after docker compose up
There is no error for this one, which makes it worse. The upstream Compose file mounts /etc/timezone:/etc/timezone:ro. Debian 13 and Ubuntu 26.04 do not ship that file. Docker creates a missing bind source as a directory, so after the first docker compose up the host has an empty directory at /etc/timezone. Anything that reads that path for the timezone name now finds a directory. Ubuntu 24.04 still has the file, so it is unaffected. Our Compose file drops the mount. The /etc/localtime mount alone is enough: with the host on Africa/Nairobi, docker exec trilium date printed the same EAT time as the host. Clean up an affected host with:
sudo rmdir /etc/timezone
Lock down a public Trilium server
Trilium has a single user and a single password, so that password guards everything. On a server reachable from the internet, add these on top of the setup above:
- Turn on two-factor authentication. Options > Password & Auth offers TOTP with recovery codes, or sign-in through an OpenID Connect provider instead of the local password.
- Keep the app on loopback.
TRILIUM_NETWORK_HOST=127.0.0.1for the tarball, or the127.0.0.1:8080port binding for Docker, means TLS through Nginx is the only way in. - Set the trusted proxy. Without it, every request appears to come from the Nginx address, and failed-login throttling cannot tell one attacker from another.
- Leave backend scripting off. The startup log confirms it is disabled by default. Enable
backendScriptingEnabledunder[Security]inconfig.inionly if you run backend scripts and trust everyone with your password and ETAPI tokens. - Cap uploads if you share the box.
client_max_body_size 0removes the Nginx limit entirely. A value such as200Mstill covers large attachments. - Treat ETAPI tokens like the password. Revoke a token in Options > ETAPI as soon as the script or MCP client that used it is gone.
With that in place, the server is a single SQLite file behind Nginx, backed up nightly and upgraded with one curl call. If you are still choosing a note app, our SiYuan Docker guide covers the closest self-hosted alternative.