A minimal, rootless Docker image for continuously syncing an Obsidian vault via obsidian-headless — the official headless client for Obsidian Sync released February 2026.
Built on s6-overlay for proper process supervision, signal handling, and ordered service startup. The container starts as root to perform one-time user/group and ownership setup, then runs the main services as a non-root user.
Requirements: An active Obsidian Sync subscription.
Fork of Belphemur/obsidian-headless-sync-docker, maintained for vault-cortex. Images:
ghcr.io/aliasunder/obsidian-headless-sync-docker.
Pull the image and run the interactive login helper. It will prompt for your Obsidian email, password, and MFA code (if enabled), then print your token.
# Docker
docker run --rm -it --entrypoint get-token ghcr.io/aliasunder/obsidian-headless-sync-docker:latest
# Podman
podman run --rm -it --entrypoint get-token ghcr.io/aliasunder/obsidian-headless-sync-docker:latestCopy the printed OBSIDIAN_AUTH_TOKEN value — you'll need it in step 3.
Note: The token persists until you explicitly log out or revoke it from your Obsidian account. You only need to run this once per machine (or per token rotation).
List the vaults available on your Obsidian Sync account:
# Docker
docker run --rm \
-e OBSIDIAN_AUTH_TOKEN=your-token-here \
--entrypoint ob \
ghcr.io/aliasunder/obsidian-headless-sync-docker:latest \
sync-list-remote
# Podman
podman run --rm \
-e OBSIDIAN_AUTH_TOKEN=your-token-here \
--entrypoint ob \
ghcr.io/aliasunder/obsidian-headless-sync-docker:latest \
sync-list-remote
⚠️ Note: Using--entrypoint obbypasses s6-overlay and runs the command as root. This is fine for read-only operations likesync-list-remote, but avoid mounting config volumes with this method as it may create root-owned files that conflict with the unprivileged service.
Note the exact vault name — you'll use it in VAULT_NAME.
cp .env.example .envEdit .env and fill in at minimum:
OBSIDIAN_AUTH_TOKEN=<token from step 1>
VAULT_NAME=My Vault
VAULT_HOST_PATH=./vault
CONFIG_HOST_PATH=./configSee Environment Variables for all options.
docker compose up -dOn first run the container performs a one-time ob sync-setup to link the local directory to your remote vault, then enters continuous sync mode. Subsequent restarts skip the setup and go straight to syncing.
Watch logs:
docker compose logs -fThe sync daemon's own output is not timestamped in the message text — add -t
(docker compose logs -f -t) to have Docker prepend a timestamp to each line.
On startup the service logs a context banner (device, conflict strategy, vault)
so you can confirm which configuration the sync is running under.
This image uses s6-overlay v3 as its init system. See docs/s6-overlay-design.md for the full design documentation.
The startup sequence runs through ordered s6-rc services:
- init-setup-user — adjusts UID/GID to match
PUID/PGID - init-check-auth — validates
OBSIDIAN_AUTH_TOKENis set - init-obsidian-login — runs
ob loginto authenticate - init-setup-vault — runs
ob sync-setupand applies optional config - svc-obsidian-sync — starts
ob sync --continuousunder s6 supervision
If any init step fails, the container exits immediately (S6_BEHAVIOUR_IF_STAGE2_FAILS=2).
Supported platforms: linux/amd64, linux/arm64.
| Variable | Required | Default | Description |
|---|---|---|---|
OBSIDIAN_AUTH_TOKEN |
Yes | — | Auth token from get-token |
VAULT_NAME |
Yes (first run) | — | Exact name of the remote Obsidian Sync vault |
VAULT_HOST_PATH |
Yes | ./vault |
Host path where vault files will be written |
CONFIG_HOST_PATH |
No | ./config |
Host path for persistent config (login state, etc.) |
VAULT_PASSWORD |
If E2E enabled | — | Vault end-to-end encryption password (see below) |
PUID |
No | 1000 |
UID that will own synced files (see below) |
PGID |
No | 1000 |
GID that will own synced files (see below) |
VAULT_PATH |
No | /vault |
In-container mount path (advanced) |
DEVICE_NAME |
No | obsidian-docker |
Label shown in Obsidian Sync history |
CONFLICT_STRATEGY |
No | conflict |
merge or conflict — conflict writes a separate conflict file instead of silently merging |
EXCLUDED_FOLDERS |
No | — | Comma-separated vault folders to skip |
FILE_TYPES |
No | — | Extra types to sync: image,audio,video,pdf,unsupported |
SYNC_MODE |
No | bidirectional |
Sync mode: bidirectional, pull-only, or mirror-remote |
SYNC_CONFIGS |
No | — | Comma-separated config categories to sync (see below) |
CONFIG_DIR_NAME |
No | .obsidian |
Name of the Obsidian config directory inside the vault (advanced) |
GHCR_REPO |
No | — | Override image repository when self-building |
At startup the container adjusts its internal obsidian user to match the PUID/PGID you provide, then drops privileges via s6-setuidgid before running any Obsidian commands. This means vault files on the host are owned by the UID/GID you choose.
Regular Docker (daemon runs as root):
# Find your UID and GID
id
# uid=1000(you) gid=1000(you) ...PUID=1000
PGID=1000Rootless Docker / Podman (daemon runs as your user):
In rootless mode, container UID 0 already maps to your host user. Set both to 0:
PUID=0
PGID=0Obsidian Sync supports optional end-to-end encryption with a separate vault password. If your vault has this enabled, ob sync-setup will fail to authenticate until the password is provided.
To check: In the Obsidian desktop app, go to Settings → Sync and look for an "Encryption password" field — if it's present and set, E2E is active.
Add the password to your .env:
VAULT_PASSWORD=your-vault-encryption-passwordNote:
VAULT_PASSWORDis the vault encryption password you chose in Obsidian, not your Obsidian account password. They are separate credentials.
⚠️ Passwords containing$: Docker Compose interpolates.envfiles, so a bare$in the value (e.g.pa$sword) is treated as the start of a variable reference and silently stripped, truncating your password. If your password contains a$, wrap it in single quotes so Compose treats it literally:VAULT_PASSWORD='pa$sword'
These variables map directly to ob sync-config options and are applied every time the container starts.
Controls how local and remote changes are reconciled.
| Value | Behaviour |
|---|---|
bidirectional |
Upload local changes and download remote changes (default) |
pull-only |
Download remote changes only — local changes are ignored |
mirror-remote |
Download remote changes only — local changes are reverted |
SYNC_MODE=pull-onlyComma-separated list of Obsidian config categories to sync alongside vault notes. Leave blank to keep the vault's existing setting (all categories synced by default).
| Value | Syncs |
|---|---|
app |
Core app settings |
appearance |
Theme and appearance settings |
appearance-data |
Theme assets (CSS snippets, etc.) |
hotkey |
Keyboard shortcuts |
core-plugin |
Core plugin toggle states |
core-plugin-data |
Core plugin configuration data |
community-plugin |
Community plugin list and toggle states |
community-plugin-data |
Community plugin configuration data |
# Sync only app settings and hotkeys
SYNC_CONFIGS=app,hotkeyFor the full reference see the obsidian-headless ob sync-config documentation.
Overrides the name of the Obsidian config directory inside the vault (default: .obsidian). Only needed if your vault uses a non-standard config directory name.
CONFIG_DIR_NAME=.obsidianMulti-arch images (linux/amd64, linux/arm64) are published to the GitHub Container Registry on demand via the Manual Release workflow (.github/workflows/manual_release.yml), with SLSA build provenance attached.
# compose.yml already points to:
image: ghcr.io/aliasunder/obsidian-headless-sync-docker:latestdocker build -t obsidian-headless-sync-docker .Then update compose.yml to use image: obsidian-headless-sync-docker.
A ready-made quadlet unit file (obsidian-sync.container) is included for running the container as a systemd service under rootless Podman.
# Copy the quadlet into the user systemd search path
mkdir -p ~/.config/containers/systemd
cp obsidian-sync.container ~/.config/containers/systemd/
# Create a secrets file (mode 600 keeps your token private)
mkdir -p ~/.config/obsidian-sync
install -m 600 /dev/null ~/.config/obsidian-sync/obsidian-sync.envPopulate ~/.config/obsidian-sync/obsidian-sync.env with at minimum:
OBSIDIAN_AUTH_TOKEN=<token from get-token>
VAULT_NAME=My VaultOptional keys (defaults are set in the unit file):
VAULT_PASSWORD=
DEVICE_NAME=obsidian-podman
CONFLICT_STRATEGY=conflict
EXCLUDED_FOLDERS=
FILE_TYPES=
SYNC_MODE=
SYNC_CONFIGS=systemctl --user daemon-reload
systemctl --user start obsidian-sync
systemctl --user status obsidian-syncWatch logs:
journalctl --user -u obsidian-sync -fEnable the built-in Podman auto-update timer to pull new images from ghcr on a schedule:
systemctl --user enable --now podman-auto-update.timerThe unit also sets Pull=newer, so it will fetch a newer image from ghcr.io each time the service restarts.
By default the vault is stored at ~/obsidian-vault. To use a different path, edit the Volume= line in the unit file before copying it:
Volume=/path/to/your/vault:/vault:zdocker compose pull
docker compose up -ddocker compose downYour vault files remain on disk at VAULT_HOST_PATH.
Container exits immediately
- Check that
OBSIDIAN_AUTH_TOKENandVAULT_NAMEare set:docker compose config - Check init logs: the container stops on any init failure (
S6_BEHAVIOUR_IF_STAGE2_FAILS=2)
"Vault not found" error on setup
- Confirm the vault name matches exactly (case-sensitive): run
ob sync-list-remoteas shown in Step 2.
"Failed to validate password" on setup
- Your vault has end-to-end encryption enabled. Set
VAULT_PASSWORDin.envto the encryption password from Obsidian → Settings → Sync. This is distinct from your Obsidian account password. - If the password contains a
$character, make sure it's wrapped in single quotes in.env(e.g.VAULT_PASSWORD='pa$sword'), otherwise Compose's variable interpolation will silently truncate it. Rundocker compose configand check the resolvedVAULT_PASSWORDvalue matches what you expect.
Sync stops after a while
- The
restart: unless-stoppedpolicy incompose.ymlwill restart the container automatically. Within the container, s6 supervises the sync process and restarts it if it exits.
Token expired / login required
- Re-run the
get-tokenstep, updateOBSIDIAN_AUTH_TOKENin.env, and restart:docker compose up -d
Permission denied on vault files
- The container adjusts its internal user to match
PUID/PGID(default1000:1000). Set these in.envto match the host user who should own the files (idshows your values). - For rootless Docker/Podman, set both to
0.
Contributions are welcome — see CONTRIBUTING.md for the build loop and PR process, and the Code of Conduct. Please report security issues privately per the Security Policy.
The packaging in this repository (Dockerfile, s6-overlay scripts, compose/quadlet
files, workflows, docs) is MIT — see LICENSE. It builds on
Belphemur/obsidian-headless-sync-docker
(also MIT).
The published image bundles obsidian-headless
(the ob CLI), which is proprietary — its package.json declares "license": "UNLICENSED"
(© Dynalist Inc. / Obsidian). It is installed from public npm at build time; the MIT license here
does not cover it, and using it requires an active Obsidian Sync subscription.