Found a bug, missing a feature, or have an idea? Open an issue — everyone's welcome to report or suggest anything. Want to help build it? Subscribe via CodeTriage and start contributing.
DeltaChat Channel to get in touch with users and developers: DeltaChat Websidian Channel
A small, self-hosted Obsidian-like markdown knowledge app.
- Backend: NestJS 11 (Express), server-rendered EJS — runs on Node.js 24 LTS
- User store: SQLite (TypeORM
sql.jsdriver) atdata/app.dbby default, or PostgreSQL (DB_TYPE=postgres) - Vaults: stored on the server's disk under
data/<storageId>/by default, in S3-compatible object storage (STORAGE_DRIVER=s3), or on any WebDAV server (STORAGE_DRIVER=webdav, e.g. Nextcloud) - Auth: username + password with mandatory TOTP 2FA, JWT in an httpOnly cookie
- Zero-knowledge end-to-end encryption: vault contents (notes, drawings, attachments) are encrypted in your browser with AES-256-GCM; the server only ever stores ciphertext it cannot read. A one-time recovery key is issued at registration to restore access if you forget your password
- Account dashboard: click your username to see storage usage against your quota and to delete your account (and all its data)
- Features: create/edit/delete markdown notes, nested folders, attachments
(PDF/jpg/png), inline
.excalidrawediting, Obsidian[[wikilinks]], filename + client-side content search, folder/.zipimport & decrypted export, light/dark theme, responsive UI
Copy .env.example to .env and adjust:
Core
| Variable | Default | Purpose |
|---|---|---|
PORT |
3065 |
HTTP port |
JWT_SECRET |
(insecure default) | Secret for signing JWTs — set a strong value. Create with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
JWT_EXPIRES_IN |
7d |
Authenticated session lifetime |
DATA_ROOT |
./data |
Where the DB and local vaults live |
APP_URL |
http://localhost:3065 |
Public base URL (Stripe redirects, default CORS origin) |
ALLOW_REGISTRATION |
true |
Set false to disable self-service registration |
MAX_REGISTRATIONS |
0 |
Cap registered users (0 = unlimited) |
COOKIE_SECURE |
false |
Set true when served over HTTPS |
CORS_ORIGINS |
(=APP_URL) |
Comma-separated browser origins allowed to call the backend |
Storage & quota
| Variable | Default | Purpose |
|---|---|---|
USER_STORAGE_ENABLED |
false |
Bring-your-own storage: each user connects their own S3/WebDAV backend (set at registration, editable in the dashboard, credentials stored encrypted). When true the shared STORAGE_DRIVER/S3_*/WEBDAV_* backend is ignored, quota is self-service (default unlimited) and billing/plans are disabled. See Bring-your-own storage. |
STORAGE_QUOTA_GB |
8 |
Per-user storage limit in GB (0 = unlimited). Ignored when USER_STORAGE_ENABLED=true |
MAX_UPLOAD_SIZE_MB |
25 |
Max JSON/urlencoded request body (caps a single note upload) |
MAX_UPLOAD_FILE_MB |
2048 |
Max size of a single uploaded/imported file (MB) |
MAX_IMPORT_FILES |
20000 |
Max files accepted in one folder/zip import |
MAX_IMPORT_TOTAL_MB |
2048 |
Max total size of a single import (MB) |
UPLOAD_REQUEST_TIMEOUT_MIN |
30 |
How long (min) the server keeps an upload request open |
UPLOAD_EXCLUDE_PATTERNS |
._*,.DS_Store,… |
Junk files excluded from uploads — comma-separated, case-insensitive globs (* wildcard) matched on the filename; empty to disable |
TUS_TMP_DIR |
<os-tmp>/websidian-tus |
Scratch dir where the tus server assembles incomplete chunked uploads |
TRASH_RETENTION_DAYS |
7 |
Days deleted items stay in trash (0 = immediate delete) |
Rate limiting
| Variable | Default | Purpose |
|---|---|---|
RATE_LIMIT_ENABLED |
true |
Throttle the /auth routes (login, register, 2fa) per user (false to disable) |
RATE_LIMIT_WINDOW_SECONDS |
60 |
Length of the auth rate-limit window in seconds |
RATE_LIMIT_MAX |
60 |
Max auth requests per window, per user/IP |
RATE_LIMIT_DASH_ENABLED |
false |
Throttle the /api dashboard data routes per user (true to enable) |
RATE_LIMIT_DASH_WINDOW_SECONDS |
60 |
Length of the dashboard rate-limit window in seconds |
RATE_LIMIT_DASH_MAX |
60 |
Max dashboard API requests per window, per user/IP |
SEARCH_CACHE_TTL_MS |
15000 |
How long (ms) the server caches a user's flat file list for name search; 0 disables. New/renamed files may take up to this long to appear in name search |
GRAPH_CACHE_TTL_MS |
300000 |
How long (ms) the client reuses an already-built wikilink graph before rebuilding; 0 disables |
MAX_OPEN_TABS |
8 |
Max files open as tabs at once. Open tabs are cached in the browser so switching never reloads; opening more is refused until a tab is closed (minimum 1) |
Encryption (DB columns at rest)
| Variable | Default | Purpose |
|---|---|---|
ENCRYPTION_ENABLED |
true |
Encrypt sensitive DB columns at rest (TOTP/Stripe) |
ENCRYPTION_KEY |
(from JWT_SECRET) | Master key for the DB-column encryption — keep stable |
Database (see Database backend)
| Variable | Default | Purpose |
|---|---|---|
DB_TYPE |
sqlite |
sqlite or postgres |
DB_HOST |
localhost |
Postgres host (when DB_TYPE=postgres) |
DB_PORT |
5432 |
Postgres port |
DB_USERNAME |
postgres |
Postgres user |
DB_PASSWORD |
(empty) | Postgres password |
DB_DATABASE |
web_obsidian |
Postgres database name |
DB_SSL |
false |
true to connect over TLS |
Vault storage backend (see Vault storage backend)
| Variable | Default | Purpose |
|---|---|---|
STORAGE_DRIVER |
local |
local (filesystem), s3 or webdav |
S3_ENDPOINT |
(empty) | S3 endpoint (when STORAGE_DRIVER=s3) |
S3_REGION |
us-east-1 |
S3 region |
S3_BUCKET |
(empty) | S3 bucket name |
S3_ACCESS_KEY_ID |
(empty) | S3 access key |
S3_SECRET_ACCESS_KEY |
(empty) | S3 secret key |
S3_FORCE_PATH_STYLE |
true |
Path-style addressing (MinIO/some S3-compatibles) |
S3_PREFIX |
(empty) | Optional key prefix so multiple apps share one bucket |
WEBDAV_URL |
(empty) | WebDAV server base URL (when STORAGE_DRIVER=webdav) |
WEBDAV_USERNAME |
(empty) | WebDAV user (empty = unauthenticated) |
WEBDAV_PASSWORD |
(empty) | WebDAV password |
WEBDAV_AUTH_TYPE |
auto |
auto, password (HTTP Basic), digest or none |
WEBDAV_BASE_PATH |
(empty) | Optional path prefix so multiple apps share one account |
Billing / subscriptions (Stripe) — optional
| Variable | Default | Purpose |
|---|---|---|
BILLING_ENABLED |
(on if STRIPE_SECRET_KEY set) |
Switch the payment feature on/off |
STRIPE_SECRET_KEY |
(empty) | Stripe secret key (enables checkout) |
STRIPE_PRICE_PLUS |
(empty) | Recurring (annual) price ID for the paid plan (STRIPE_PRICE_5GB read as fallback) |
STORAGE_PLUS_GB |
3 |
Storage size of the paid plan, in whole GB |
PRICE_PLUS |
(empty) | Display-only suggested donation (e.g. €10 / year); PRICE_5GB fallback |
CONTACT_EMAIL |
(empty) | Contact shown for custom/larger storage requests |
PRIVILEGED_USERS |
(empty) | Comma-separated usernames (e.g. userA,userB) granted free dedicated storage; excluded from billing, no upgrade button. Independent of STORAGE_QUOTA_GB/STORAGE_PLUS_GB. Unioned with the privileged_users DB table |
STORAGE_PRIVILEGED_USERS_GB |
20 |
Storage allowance for privileged users, in whole GB |
Legal pages (opt-in — hidden unless set to true)
| Variable | Default | Purpose |
|---|---|---|
AGB |
false |
Show AGB (German terms & conditions) page |
IMPRINT |
false |
Show Imprint page |
LEGAL_NOTICE |
false |
Show Privacy policy page |
CODETRIAGE_BADGE |
(upstream badge) | CodeTriage contribution badge URL (developer area) |
DELTACHAT_CHANNEL |
(upstream channel) | DeltaChat channel invite URL (developer area) |
Self-hosting
| Variable | Default | Purpose |
|---|---|---|
CLOUDFLARE_TOKEN |
(empty) | Cloudflare Tunnel token (HTTPS without opening ports) |
Generate a secret with openssl rand -hex 32.
DB_TYPE selects the database (default sqlite). For PostgreSQL set
DB_TYPE=postgres and configure DB_HOST, DB_PORT, DB_USERNAME,
DB_PASSWORD, DB_DATABASE, and DB_SSL. See .env.example for the full list.
STORAGE_DRIVER selects where vault files are stored (default local, the
server's filesystem). An s3 driver targeting S3-compatible object storage
(AWS S3, MinIO, Mega S3, etc.) is available with full configuration
(S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE, S3_PREFIX). Either way the
backend is a blind blob store: files are stored under an immutable random
storageId per account (never the username) and their contents are already
end-to-end encrypted ciphertext.
A webdav driver stores the same encrypted blobs on any WebDAV server under
WEBDAV_BASE_PATH/<storageId>/…. Configure it with WEBDAV_URL,
WEBDAV_USERNAME, WEBDAV_PASSWORD, WEBDAV_AUTH_TYPE (auto by default) and
an optional WEBDAV_BASE_PATH. Because the server only ever writes ciphertext,
the WebDAV host cannot read your notes.
Tested / compatible WebDAV services (anything speaking standard WebDAV should work):
- Nextcloud / ownCloud — URL like
https://cloud.example.com/remote.php/dav/files/<user> - Seafile (WebDAV extension)
- Synology NAS (WebDAV Server package) and QNAP / TrueNAS
- Apache (
mod_dav) or Nginx WebDAV modules — your own server - Infomaniak kDrive, IONOS HiDrive / Strato HiDrive, Koofr, pCloud, Yandex Disk, mailbox.org, Fastmail
rclone serve webdav— bridges almost any other cloud (Google Drive, Dropbox, Backblaze B2, OneDrive, …) to a local WebDAV endpoint
Note: end-to-end encryption already protects your content, so a provider's own "client-side encryption" is not required.
Set USER_STORAGE_ENABLED=true to stop hosting storage entirely: the server
keeps no default backend and each user connects their own storage instead.
- At registration the new account picks a backend after setting up 2FA — either an S3-compatible object store (Mega S4, Hetzner Object Storage, AWS S3, …) or a WebDAV server (Nextcloud, Box, Koofr, CloudMe, …), enters the credentials, and tests the connection before finishing. The provider choice is not critical because everything is end-to-end encrypted before upload — pick one you trust. websidian is tested end-to-end with Nextcloud (WebDAV) and Mega S4 (S3).
- In the dashboard the user can change the provider/credentials at any time and set a self-imposed quota (in GB; default unlimited — it is their own storage). The usage bar still shows how much space they have used.
- Existing accounts without a provider see a one-time prompt to connect one (no need to register again).
- Credentials are stored encrypted at rest (the same column encryption used
for TOTP secrets, governed by
ENCRYPTION_KEY). Connection failures surface a short reason; if it cannot be resolved, the user is pointed toCONTACT_EMAIL.
In this mode the shared STORAGE_DRIVER / S3_* / WEBDAV_* settings and
billing/plans (STORAGE_QUOTA_GB, Stripe) are ignored.
Each account is limited to STORAGE_QUOTA_GB (default 8 GB). Writes,
uploads and imports that would exceed the quota are rejected. Set
STORAGE_QUOTA_GB=0 for unlimited storage. (Paid upgrades for more storage are
planned.)
Two independent limiters guard the app, both keyed per user (per IP for
anonymous callers behind a NAT). When a limit is exceeded the endpoint responds
with HTTP 429 and the UI shows a clear toast asking the user to slow down.
Auth limiter (/auth/*: login, register, 2fa) throttles credential
guessing and account enumeration. It is enabled by default. Tune it with
RATE_LIMIT_WINDOW_SECONDS (default 60s = "per minute") and RATE_LIMIT_MAX
(default 60 requests per window); set RATE_LIMIT_ENABLED=false to disable.
Dashboard limiter (/api/*: vault data routes) stops a single account from
hammering the storage backend — for example by reloading the page in a loop —
which caps S3/Mega S3 request costs and blunts trivial DDoS attempts. It is
disabled by default; set RATE_LIMIT_DASH_ENABLED=true to enable, then tune
with RATE_LIMIT_DASH_WINDOW_SECONDS and RATE_LIMIT_DASH_MAX. Note that one
page load fires several API calls (file tree + the opened note, etc.), so keep
RATE_LIMIT_DASH_MAX comfortably above the page reloads per minute you allow.
Vault contents are end-to-end encrypted in your browser — the server never sees your password or any plaintext, and stores only opaque ciphertext.
- At registration the browser generates a random 256-bit vault key (VK) and encrypts every note, drawing and attachment with AES-256-GCM under it.
- The VK is wrapped twice: once with a key derived from your password (PBKDF2-SHA256, 600,000 iterations) and once with a key derived from a one-time recovery key. Only these two wrapped blobs are stored on the server; the VK itself never leaves your device.
- Because the server cannot read your files, markdown rendering, full-text
content search, and
.zipexport all run in your browser. Filenames and folder names stay in plaintext so the vault tree still works server-side; only file contents are encrypted.
Encrypted blobs use the format MAGIC("WOE1") | iv(12) | ciphertext+tag.
Keep your recovery key. It is shown once during sign-up and is the only way back in if you forget your password. If you lose both your password and your recovery key, your data is unrecoverable — by design, nobody (including the operator) can decrypt it for you.
The separate server-side ENCRYPTION_KEY (ENCRYPTION_ENABLED=true) is used
only to encrypt sensitive database columns (TOTP secrets, Stripe IDs), not
vault content. Set a dedicated, stable value (openssl rand -hex 32) and back
it up.
The Export feature decrypts your vault in the browser and produces a
decrypted .zip, so you always keep a portable, platform-independent
backup.
Requires Node.js 24+.
npm install
npm run build:client # bundles the in-browser editors, crypto, markdown & zip
npm run start:dev # or: npm run build && npm run start:prodOpen http://localhost:3065, register an account, scan the QR code (or type the shown secret) into an authenticator app, and confirm the 6-digit code. During sign-up you are shown a one-time recovery key — save it before continuing; it is the only way to regain access if you forget your password.
The published image targets ARM (linux/arm64) for Raspberry Pi and other ARM Linux servers.
JWT_SECRET=$(openssl rand -hex 32) docker compose up -d --buildApplication data is persisted in ./data (mounted into the container at
/app/data). Back up that folder on the host. To disable registration after
creating your account, set ALLOW_REGISTRATION=false and restart.
Click your username in the top-right to open the account dashboard. It shows your current storage consumption against your quota and lets you delete your account. Deletion is permanent: it removes all your notes and files from storage and your account record from the database (you must re-enter your password to confirm).
Use the Import button in the sidebar (or drag a folder onto the file tree) and pick a folder — its structure is preserved. You can also drag individual files in. Several-GB folders with thousands of files are fine.
Every file is encrypted in your browser before the first byte leaves, so the server only ever receives ciphertext.
Folder uploads run over the tus resumable protocol
(Uppy + @uppy/tus on the client, @tus/server on the backend, endpoint
/files). This solves a hard constraint: the deployment sits behind a
Cloudflare Zero Trust proxy that caps every HTTP request body at 100 MB.
- Chunk size — why 50 MB. Each file is uploaded in 50 MB chunks
(
MAX_UPLOAD_FILE_MBbounds a whole file, not a chunk). One chunk plus the tus protocol headers is comfortably under 100 MB, so no request ever trips the Cloudflare limit, with margin to spare. The chunk size is set inclient/upload-entry.js(CHUNK_SIZE). - Folder structure. For each file the browser reads its
webkitRelativePathand sends it (path minus filename) as the tusrelativePathmetadata, plusfilenameand the destinationbasefolder. On completion the server recreates the tree under the user's vault root. - How resume works. tus tracks a per-upload byte offset;
@uppy/tusstores the upload URL inlocalStorage. If the connection drops or the page reloads, re-selecting the same folder resumes each file from its stored offset instead of restarting at 0 — the progress UI shows it continuing. To make this safe with end-to-end encryption, files are encrypted deterministically (WOCrypto.encryptBytesDeterministic, IV derived from the plaintext's SHA-256), so re-encrypting a file after a refresh produces byte-identical ciphertext and the resumed bytes line up. - Path sanitization. The client path is never trusted. Before writing,
sanitizeRelPath(src/common/path-safety.ts) normalizes backslashes, strips leading slashes, and rejects..segments, drive letters (C:), and NUL bytes. A traversal attempt fails with a 400 rather than escaping the vault. - Concurrency & retries. Up to 3 files upload in parallel; network errors auto-retry with backoff. The browser keeps only a few encrypted files in memory at once regardless of folder size.
- Junk-file exclusion. OS metadata sidecars (macOS
._*AppleDouble forks,.DS_Store, WindowsThumbs.db/desktop.ini, …) are skipped before queueing and also rejected server-side, so they never silently consume vault storage. The list is configurable viaUPLOAD_EXCLUDE_PATTERNS(see the env table); the upload panel reports how many files were skipped.
Anything that does land in your vault is fully visible in the file tree —
including dotfiles such as .obsidian or stray junk left over from older
uploads — so you can always see and delete what occupies your storage. Only the
internal .keep folder placeholders and the .trash recycle area stay hidden.
The old workaround — zip a folder, upload one .zip, expand it client-side — is
gone. Native folder uploads with preserved structure replace it. Removed:
- the
.zipchoice in the Import dialog and its client-side unpack path (runImport); the export feature still builds a.zipin the browser, so the zip helper (WOZip.zip) stays. - the old bulk
POST /api/importmultipart endpoint (it sent the whole folder in one request, which broke the 100 MB limit). Single small inline attachments still usePOST /api/upload. - the unused
adm-zipdependency. There was never a server-side unzip step — the server only ever held ciphertext — so nothing else changed on the backend.
- Each chunk request is < 100 MB, satisfying the Cloudflare body limit. Confirm
the origin (nginx/ingress) allows it too: set
client_max_body_sizeto at least ~55 MB (one 50 MB chunk plus headers); larger is fine. - The proxy and CORS must pass the tus headers both ways:
Tus-Resumable,Upload-Offset,Upload-Length,Upload-Metadata,Upload-Defer-Length,Upload-Concat, andLocation. These are allow-listed insrc/main.ts(enableCors) and advertised by the tus server; a stripping proxy in front would break resume. TUS_TMP_DIRmust be writable with room for the largest in-flight upload.
Pushing to main triggers the Forgejo Actions pipeline
(.forgejo/workflows/release.yml) which builds and tests the project, creates a
git tag, and builds & pushes a linux/arm64 Docker image to the Github
container registry. Configure a PACKAGE_TOKEN secret (Github access token
with package read/write scopes) in the repository's Actions settings.
websidian is source-available under the PolyForm Noncommercial License 1.0.0.
You may self-host it for your own personal, noncommercial use, study it, and
modify it; you may also use the official hosted version or deploy the official
Docker image on your own server. You may not use it for any commercial
purpose, including offering it to others as a hosted service. The LICENSE file
governs in case of any conflict with this summary.
run the following command twice
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"