← Back to home

Self-hosting Neutrino

Neutrino ships as one container: a binary serving the API and the web app, and a background worker beside it, backed by SQLite. There is no external database, message queue or object store to stand up first. A small VPS, a NAS or a Raspberry Pi is enough to run the whole suite for a household or a team.

What you need

  • A Linux host — x86-64 or ARM64. 1 vCPU and 1 GB of RAM runs a small instance comfortably.
  • Disk space for whatever you plan to store, plus a little headroom. Files live on the filesystem, not in the database. Budget for version history too: every saved version of a file is a full copy beside the current one, and how much is kept is yours to set under Admin → Versions — an age to keep versions for, and a number of recent versions to keep regardless. The worker prunes to that policy hourly.
  • Docker, if you want the one-command install. Otherwise just the binary.
  • A hostname and TLS certificate if you intend to reach it from outside your network. See TLS and reverse proxy.
Two secrets are mandatory. The server refuses to start withoutJWT_SECRET andWORKER_SECRET. Generate them once, store them somewhere safe, and reuse them across restarts — regeneratingJWT_SECRET invalidates every session.

Install with Docker

The published image contains the Rust server, the background worker and the prebuilt web app, and its default command runs the server and the worker together. It listens on port 8080 and keeps all state under/usr/local/data, which is declared as a volume — mount it somewhere durable or an upgrade will take your data with it.

Give it a restart policy. If either process stops, the container stops with it, rather than staying up while quietly doing none of the background work — face detection, account deletion and version pruning all live in the worker. --restart unless-stopped (below) is what brings it back.
Terminal
# Generate your secrets once and keep them
export JWT_SECRET="$(openssl rand -hex 32)"
export WORKER_SECRET="$(openssl rand -hex 32)"

docker run -d --name neutrino \
  -p 8080:8080 \
  -e JWT_SECRET="$JWT_SECRET" \
  -e WORKER_SECRET="$WORKER_SECRET" \
  -e DATABASE_URL=/usr/local/data/neutrino.db \
  -e STORAGE_PATH=/usr/local/data/storage \
  -e APP_BASE_URL=https://neutrino.example.com \
  -e SELF_URL=https://neutrino.example.com \
  -e DRIVE_URL=https://neutrino.example.com \
  -v neutrino-data:/usr/local/data \
  --restart unless-stopped \
  ghcr.io/wcherry/neutrino:latest

Then open http://your-host:8080. The database migrations run automatically on first boot — there is no separate migration step.

Docker Compose

Easier to live with than a long docker run. Put the secrets in a .env file next to this one and keep it out of version control.

docker-compose.yml
services:
  neutrino:
    image: ghcr.io/wcherry/neutrino:latest
    container_name: neutrino
    restart: unless-stopped
    ports:
      - "8080:8080"
    environment:
      JWT_SECRET: ${JWT_SECRET}
      WORKER_SECRET: ${WORKER_SECRET}
      DATABASE_URL: /usr/local/data/neutrino.db
      STORAGE_PATH: /usr/local/data/storage
      APP_BASE_URL: https://neutrino.example.com
      SELF_URL: https://neutrino.example.com
      DRIVE_URL: https://neutrino.example.com
      LOG_LEVEL: info
      LOG_PATH: /usr/local/logs
    volumes:
      - ./data:/usr/local/data
      - ./logs:/usr/local/logs
Terminal
docker compose up -d
docker compose logs -f neutrino

Both processes log to stdout, so docker compose logs shows everything interleaved. SettingLOG_PATH additionally gives each its own daily file in ./logsservice.<date>.log andworker.<date>.log — which is easier to read back when you want one of them on its own.

Install from a binary

If you would rather not run Docker, grab a release, give it a config and run it under systemd. The binary needs the built web app on disk and pointed at by WEB_DIR.

Terminal
# Fetch the latest release for your platform — the server and the worker
curl -fsSL -o neutrino \
  https://github.com/wcherry/neutrino/releases/latest/download/neutrino-linux-x86_64
curl -fsSL -o neutrino-worker \
  https://github.com/wcherry/neutrino/releases/latest/download/neutrino-worker-linux-x86_64
chmod +x neutrino neutrino-worker
sudo mv neutrino neutrino-worker /usr/local/bin/

# Somewhere for state to live
sudo mkdir -p /var/lib/neutrino/storage
sudo useradd --system --home /var/lib/neutrino neutrino
sudo chown -R neutrino:neutrino /var/lib/neutrino

Put the configuration in an environment file rather than in the unit, so the secrets are not world-readable in systemctl show:

/etc/neutrino/neutrino.env
JWT_SECRET=replace-me-with-openssl-rand-hex-32
WORKER_SECRET=replace-me-too
DATABASE_URL=/var/lib/neutrino/neutrino.db
STORAGE_PATH=/var/lib/neutrino/storage
WEB_DIR=/usr/local/share/neutrino/web
APP_BASE_URL=https://neutrino.example.com
SELF_URL=https://neutrino.example.com
DRIVE_URL=https://neutrino.example.com
PORT=8080
LOG_LEVEL=info
/etc/systemd/system/neutrino.service
[Unit]
Description=Neutrino
After=network-online.target
Wants=network-online.target

[Service]
User=neutrino
Group=neutrino
EnvironmentFile=/etc/neutrino/neutrino.env
ExecStart=/usr/local/bin/neutrino
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/neutrino

[Install]
WantedBy=multi-user.target
Terminal
sudo chmod 600 /etc/neutrino/neutrino.env
sudo systemctl daemon-reload
sudo systemctl enable --now neutrino
sudo systemctl status neutrino

That unit runs the server only. Unlike the Docker image, a binary install does not start the worker for you, and without it face detection never runs, deleted accounts are never erased and version history is never pruned. Give it a unit of its own, sharing the same environment file so both processes agree on the database and storage paths:

/etc/systemd/system/neutrino-worker.service
[Unit]
Description=Neutrino background worker
# The server runs the database migrations, so start after it.
After=neutrino.service
Requires=neutrino.service

[Service]
User=neutrino
Group=neutrino
EnvironmentFile=/etc/neutrino/neutrino.env
ExecStart=/usr/local/bin/neutrino-worker
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/neutrino

[Install]
WantedBy=multi-user.target
Terminal
sudo systemctl enable --now neutrino-worker
sudo systemctl status neutrino-worker

The worker needs the face-detection model on disk — pointFACE_MODEL_PATH at it in the same environment file, or the worker exits on startup saying it could not load it.

First run

  1. Open your instance and register the first account. Do this immediately — registration is open, so the first person to reach a fresh instance gets an account on it.
  2. Turn on two-factor authentication in Settings. The server supports TOTP, so any authenticator app works.
  3. Save your encryption key. Docs, Sheets, Slides and Notes are end-to-end encrypted with a key generated in your browser. The server never sees it, which also means it cannot recover it for you. Export it and store it with your other credentials before you put real work in.
  4. Set quotas from the admin panel if more than one person will use the instance.
  5. Check the API docs at /swagger-ui/ if you plan to script against it.
The encryption key is not recoverable. Losing it means losing the contents of every encrypted document — self-hosting the server does not change that, because the server never held the key in the first place.

Configuration reference

Everything is read from the environment, or from a.env file in the working directory.

Required

VariableDefaultDescription
JWT_SECRETSecret used to sign access and refresh tokens. Generate with openssl rand -hex 32. Changing it signs everyone out.
WORKER_SECRETShared secret the background worker authenticates with. Generate the same way; never reuse JWT_SECRET.

Paths and networking

VariableDefaultDescription
PORT8080HTTP listen port.
DATABASE_URL./data/neutrino.dbPath to the SQLite database file. Point this at your mounted volume.
STORAGE_PATH./data/storageRoot directory for uploaded files. Put it on the same volume as the database.
APP_BASE_URLhttp://localhost:<PORT>Public base URL used in links sent to users. Set this to your real hostname or emailed links will point at localhost.
SELF_URLhttp://localhost:<PORT>Public base URL of this server.
DRIVE_URLhttp://localhost:<PORT>Public URL of the Drive service.
MAX_UPLOAD_BYTES10737418240Largest single-file upload, in bytes. Defaults to 10 GiB.
LOG_LEVELinfoOne of error, warn, info, debug, trace.
LOG_PATH(stdout only)Directory for log files, written as service.<date>.log and worker.<date>.log and rotated daily. Leave unset to log to stdout only, which is what you want if you collect logs off the container.
WEB_DIRweb/apps/web/outPath to the built web app. Already set correctly inside the Docker image.

Optional

VariableDefaultDescription
JWT_ACCESS_EXPIRY_SECS900Access token lifetime in seconds.
JWT_REFRESH_EXPIRY_SECS604800Refresh token lifetime in seconds. Defaults to 7 days.
JOBS_PER_WORKER4Maximum concurrent background jobs per worker.
FACE_MODEL_PATHmodels/seeta_fd_frontal_v1.0.binFace-detection model, read by the worker at startup. Already set correctly inside the Docker image; a binary install needs the file on disk and this pointed at it.
GOOGLE_CLIENT_ID(optional)Google OAuth client ID, for calendar sync.
GOOGLE_CLIENT_SECRET(optional)Google OAuth client secret.
GOOGLE_REDIRECT_URI<origin>/calendar/settings/oauth/google/callbackOAuth redirect URI. Derived from the address the browser reached the app on, so it normally needs no setting — register that URL in the Google console. Set it only to override.
OUTLOOK_CLIENT_ID(optional)Microsoft OAuth client ID, for calendar sync.
OUTLOOK_CLIENT_SECRET(optional)Microsoft OAuth client secret.
OUTLOOK_REDIRECT_URI<origin>/calendar/settings/oauth/outlook/callbackMicrosoft OAuth redirect URI, derived the same way — register that URL in the Azure portal.

TLS and reverse proxy

Neutrino speaks plain HTTP and expects something in front of it to terminate TLS. Browsers gate the Web Crypto APIs that the end-to-end encryption depends on behind a secure context, so anything other thanlocalhost needs to be served over HTTPS — this is not optional in practice.

Caddyfile
neutrino.example.com {
	reverse_proxy localhost:8080
}

Or with nginx, raising the body limit so large uploads survive the proxy:

nginx
server {
    server_name neutrino.example.com;
    listen 443 ssl http2;

    # Match or exceed MAX_UPLOAD_BYTES; 0 disables the check entirely.
    client_max_body_size 0;
    proxy_request_buffering off;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        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;

        # Live collaboration and note sync use WebSockets.
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}
Set APP_BASE_URL,SELF_URL andDRIVE_URL to the public HTTPS hostname. They are what share links and emails are built from, so if they still say localhost those links will be wrong for everyone but you.

Backups

There are two things to back up: the SQLite database and the storage directory. The database runs in WAL mode, so copying the file while the server is running can capture a torn state — usesqlite3 .backup, which takes a consistent snapshot without stopping anything.

Terminal
#!/bin/sh
set -eu
STAMP="$(date +%Y%m%d-%H%M%S)"
DEST="/backups/neutrino/$STAMP"
mkdir -p "$DEST"

# Consistent database snapshot, safe while the server is running
sqlite3 /var/lib/neutrino/neutrino.db ".backup '$DEST/neutrino.db'"

# File contents
rsync -a --delete /var/lib/neutrino/storage/ "$DEST/storage/"

# Keep the last 14 days
find /backups/neutrino -maxdepth 1 -mtime +14 -type d -exec rm -rf {} +

Back up your JWT_SECRET andWORKER_SECRET alongside the data. Restoring a database against a different JWT_SECRETsigns out every user; it does not lose data, but it is a surprise you do not want during a restore. Test a restore before you need one.

Upgrades

Migrations are embedded in the binary and run on startup, so an upgrade is pull, restart, done. Take a backup first — migrations move forward only, and there is no downgrade path.

Terminal
# Back up first (see above), then:
docker compose pull
docker compose up -d

# Or for a binary install:
sudo systemctl stop neutrino
sudo curl -fsSL -o /usr/local/bin/neutrino \
  https://github.com/wcherry/neutrino/releases/latest/download/neutrino-linux-x86_64
sudo chmod +x /usr/local/bin/neutrino
sudo systemctl start neutrino

Troubleshooting

The server exits immediately on start

Almost always a missing JWT_SECRET orWORKER_SECRET. Check the first few lines of the log — the failure is reported before anything else happens.

The container keeps restarting

The container runs the server and the worker, and stops if either one does, so a crash loop can be coming from either. The last lines before each exit say which: the start script prints worker exited or server exited with its status. A worker that dies at startup is usually the face-detection model — see FACE_MODEL_PATH.

Faces, deletions or old versions are never processed

All three are the worker's: face grouping, erasing accounts once their deletion grace window closes, and pruning version history. If you overrode the container's command, or run from a binary install without a second unit for it, the worker is not running. Look for background worker started in the log.

Uploads fail for large files

Your reverse proxy is rejecting the body before it reaches Neutrino. Raiseclient_max_body_size in nginx (or the equivalent) to at least MAX_UPLOAD_BYTES, and turn off request buffering so uploads stream rather than landing on the proxy's disk first.

Editors will not open, or encryption errors appear

The browser is not in a secure context. Serve the instance over HTTPS, or usehttp://localhost for local testing.

Share links point at localhost

APP_BASE_URL,SELF_URL andDRIVE_URL are still on their defaults. Set all three to the public hostname and restart.

Live collaboration does not connect

WebSocket upgrades are being dropped by the proxy. Forward theUpgrade andConnection headers, and raise the read timeout so idle sockets are not culled mid-session.

Still stuck

Turn up the logs with LOG_LEVEL=debug, and check the API surface at /swagger-ui/. If it looks like a bug, open an issue on GitHub with the log lines around the failure.

Rather not run a server?

The hosted version is the same software, kept up to date for you. You can export everything and move to your own box whenever you like.