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.
JWT_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.
--restart unless-stopped (below) is what brings it back.# 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:latestThen 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.
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/logsdocker compose up -d
docker compose logs -f neutrinoBoth processes log to stdout, so docker compose logs shows everything interleaved. SettingLOG_PATH additionally gives each its own daily file in ./logs —service.<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.
# 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/neutrinoPut the configuration in an environment file rather than in the unit, so the secrets are not world-readable in systemctl show:
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[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.targetsudo chmod 600 /etc/neutrino/neutrino.env
sudo systemctl daemon-reload
sudo systemctl enable --now neutrino
sudo systemctl status neutrinoThat 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:
[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.targetsudo systemctl enable --now neutrino-worker
sudo systemctl status neutrino-workerThe 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
- 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.
- Turn on two-factor authentication in Settings. The server supports TOTP, so any authenticator app works.
- 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.
- Set quotas from the admin panel if more than one person will use the instance.
- Check the API docs at
/swagger-ui/if you plan to script against it.
Configuration reference
Everything is read from the environment, or from a.env file in the working directory.
Required
| Variable | Default | Description |
|---|---|---|
JWT_SECRET | — | Secret used to sign access and refresh tokens. Generate with openssl rand -hex 32. Changing it signs everyone out. |
WORKER_SECRET | — | Shared secret the background worker authenticates with. Generate the same way; never reuse JWT_SECRET. |
Paths and networking
| Variable | Default | Description |
|---|---|---|
PORT | 8080 | HTTP listen port. |
DATABASE_URL | ./data/neutrino.db | Path to the SQLite database file. Point this at your mounted volume. |
STORAGE_PATH | ./data/storage | Root directory for uploaded files. Put it on the same volume as the database. |
APP_BASE_URL | http://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_URL | http://localhost:<PORT> | Public base URL of this server. |
DRIVE_URL | http://localhost:<PORT> | Public URL of the Drive service. |
MAX_UPLOAD_BYTES | 10737418240 | Largest single-file upload, in bytes. Defaults to 10 GiB. |
LOG_LEVEL | info | One 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_DIR | web/apps/web/out | Path to the built web app. Already set correctly inside the Docker image. |
Optional
| Variable | Default | Description |
|---|---|---|
JWT_ACCESS_EXPIRY_SECS | 900 | Access token lifetime in seconds. |
JWT_REFRESH_EXPIRY_SECS | 604800 | Refresh token lifetime in seconds. Defaults to 7 days. |
JOBS_PER_WORKER | 4 | Maximum concurrent background jobs per worker. |
FACE_MODEL_PATH | models/seeta_fd_frontal_v1.0.bin | Face-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/callback | OAuth 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/callback | Microsoft 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.
neutrino.example.com {
reverse_proxy localhost:8080
}Or with nginx, raising the body limit so large uploads survive the proxy:
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;
}
}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.
#!/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.
# 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 neutrinoTroubleshooting
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.