Skip to content

Deploying Dvarim on the home server

How the original copy is run: one Linux machine, Docker Compose, the to-dos in an Obsidian vault that Syncthing carries to the other devices. The paths are that machine's; put your own in their place.

Build and run

The dvarim service lives in /srv/docker/docker-compose.yml with the rest of the stack, right after radicale. It is the only service there built from source rather than pulled, and the source is this directory.

cd /srv/dev/dvarim
frontend/build.sh                 # builds frontend/dist through the node:22 container
cd /srv/docker && docker compose up -d --build dvarim
curl -s http://127.0.0.1:8420/api/auth

Always pass --build. up -d --force-recreate restarts the container from the image it already has, so server changes look deployed and are not. Check with docker exec dvarim ls -l /app/server/src if in doubt. A stack-wide docker compose up -d never rebuilds it; a stack-wide docker compose up -d --build does, from whatever is in this working tree at that moment, uncommitted edits included.

Three directories, and they are deliberately separate:

/srv/dev/dvarim the source and the Dockerfile — this repo
/srv/docker/dvarim auth.json and dvarim.env: the password hash, session secret and CalDAV credentials; requests.json, the requests for an account; and people/, one folder per invited person (docs/PEOPLE.md). Outside git, and outside the Syncthing-synced vault
/srv/obsidian/MyVault/Tasks the data. Mounted at /data; the Markdown files are the database

The service declares no networks: key, and that is the point: the docker project's default network is docker_default, which is where radicale already sits, so DVARIM_CALDAV_URL=http://radicale:5232/me/ resolves by name with no external-network plumbing. Verify after any change with:

docker exec dvarim python -c "import socket; print(socket.gethostbyname('radicale'))"

The container serves the API and the built frontend on port 8420, bound to localhost (which is what the Cloudflare tunnel reaches for dvarim.tiresia.xyz) and to this machine's Tailscale address.

Deploying itself

A push to master reaches the server on its own. tools/autodeploy.sh runs from cron every five minutes and is silent unless it does something:

*/5 * * * * /srv/dev/dvarim/tools/autodeploy.sh --quiet >> $HOME/logs/cron.log 2>&1

It refuses rather than guesses. This checkout is also where the code gets edited by hand, so a dirty working tree, a detached HEAD, a branch other than master or a history that has diverged from origin all stop it — it says so once and changes nothing. When it does run: fast-forward, the engine's and the server's tests (tools/test-gate.sh, in Node, in Docker), frontend build, up -d --build dvarim, then poll /api/auth until it returns 200. Anything that fails before the build leaves the tree where it was; a deploy that never answers is rolled back to the image it replaced and the tree reset to the previous commit.

Watch it with grep dvarim-deploy ~/logs/cron.log, run it by hand with tools/autodeploy.sh, and turn it off by removing that crontab line.

The server has no dependencies to install: the engine and the server use Node's own library, so nothing on the host needs updating for a deploy.

Reach it from the Mac and the phone

It is one Docker container listening on port 8420, bound to localhost and to this machine's Tailscale address. Nothing else is in front of it.

http://<tailscale-ip>:8420 works from any device on the tailnet, as does http://<hostname>:8420 via MagicDNS. No sudo, no reverse proxy. That is the fastest way in and is enough for daily use in a browser tab.

Plain HTTP has one limit: Chrome will not offer "Install app" on a non-secure origin, so on Android you get a website rather than a home-screen app. Safari on the Mac will still Add to Dock over HTTP. For a real install on both, put Tailscale's HTTPS in front of it once (this needs sudo):

sudo tailscale serve --bg --https=443 http://127.0.0.1:8420

The app is then at https://<hostname>.<tailnet>.ts.net/. sudo tailscale serve status shows the mapping and sudo tailscale serve --https=443 off removes it.

  • Mac: open the URL in Safari, then File → Add to Dock. It gets its own window and dock icon. In Chrome, use Install from the address bar.
  • Android: open in Chrome, then menu → Add to Home screen. The manifest and service worker are in place, so over HTTPS it installs as a real app.

If you want a password in front of it, add a Caddy site with a basic_auth block, pointing at dvarim:8420, and drop the Tailscale-IP port binding in /srv/docker/docker-compose.yml.

Where the data lives

Production root is /srv/obsidian/MyVault/Tasks inside the Obsidian vault, so Syncthing carries every change to the Mac and the phone and Obsidian can read and edit the same files. /srv/dev/dvarim/data (a copy of sample-vault/) is only for development; run against it with DVARIM_ROOT=/srv/dev/dvarim/data.

On first start the app creates Inbox.md, Standalone.md, Trash.md, Areas/, Projects/, Trash/ and a hidden .dvarim/ for view state.

Other notes already in that folder are left alone, whatever front matter they carry: the indexer only adopts a root-level file that declares type: project or type: area, plus everything under Areas/ and Projects/. The rest stay invisible in the app and unchanged on disk. Converting them into real Dvarim projects would be a one-off import script, not something the app does on its own.

.dvarim/ is in the vault's .gitignore, so view state is not committed.

Environment

Variable Default Meaning
DVARIM_ROOT ./data the folder of Markdown files
DVARIM_STATIC ../frontend/dist built frontend to serve at /
DVARIM_WATCH 1 set 0 to disable the file watcher (tests)
DVARIM_MERGE_ARRIVALS 0 set 1 only if the folder's sync keeps the newer file of two and leaves no conflict copy (not Syncthing): see docs/SYNC.md, "When a file arrives"
TZ UTC what "today" means. It must match where you are: get it wrong and Today rolls over at the wrong hour and calendar events show at the wrong time
DVARIM_CALDAV_URL/USER/PASS unset phase 2: Radicale calendars in Today and Upcoming
DVARIM_PEOPLE people/ beside DVARIM_AUTH_FILE the folders of the people the owner invites (docs/PEOPLE.md); refused inside DVARIM_ROOT

Backups

The data folder is plain text. It is already covered by whatever backs up the vault (git + Syncthing versions). The other people's folders are not in the vault: they sit in /srv/docker/dvarim/people/, which the machine's nightly backup of /srv/docker covers.

Signing in

The app is on the public internet at https://dvarim.tiresia.xyz through the Cloudflare tunnel, so it requires a password before it serves anything under /api. The static shell is public, which is harmless: it holds no data and shows only the login screen until you sign in.

docker exec -it dvarim node server/src/cli.js set-password
docker exec dvarim node server/src/cli.js add-token signal-bot
docker exec dvarim node server/src/cli.js list-tokens
docker exec dvarim node server/src/cli.js logout-everywhere   # if a device is lost

Where the secrets live. DVARIM_AUTH_FILE=/secrets/auth.json, bind-mounted from /srv/docker/dvarim, deliberately outside the vault. The data root is Syncthing-synced, so a password hash and a session secret kept beside the Markdown files would be copied to every device. .dvarim is in the vault's .stignore and .gitignore for the same reason.

What protects it

Concern Answer
Guessing the password scrypt, and three free tries per address then a doubling lockout
Reading or forging the cookie HMAC-SHA256 over the payload, HttpOnly, Secure, SameSite=Strict
A hostile page acting as you SameSite plus an explicit same-origin check on every write
A device you no longer have logout-everywhere rotates the secret and invalidates every session; remove its sync token in Settings → Sync
Bots that cannot log in named bearer tokens, revocable one at a time

Sessions last 30 days. Signing out in Settings ends only that browser's session.

Devices that sync with it. The server is also the folder the built-in sync uses (/dav/, docs/SYNC.md). Each device gets a token of its own from Settings → Sync → Add a device, and removing it there stops it at once. The tunnel passes WebDAV's methods (PROPFIND, MKCOL) through to the container as it passes any other. A token opens the vault's notes and the two state files, nothing else: auth.json, vtodo.json and the conflict copies are never offered.