Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

112 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

websidian

Contributors

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.

Open Source Helpers

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.js driver) at data/app.db by 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 .excalidraw editing, Obsidian [[wikilinks]], filename + client-side content search, folder/.zip import & decrypted export, light/dark theme, responsive UI

Configuration

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.

Database backend

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.

Vault storage backend

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.

Bring-your-own storage

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 to CONTACT_EMAIL.

In this mode the shared STORAGE_DRIVER / S3_* / WEBDAV_* settings and billing/plans (STORAGE_QUOTA_GB, Stripe) are ignored.

Storage quota

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.)

Rate limiting

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.

Zero-knowledge end-to-end encryption

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 .zip export 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.

Run locally

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:prod

Open 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.

Run with Docker

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 --build

Application 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.

Account & storage dashboard

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).

Importing an existing Obsidian vault

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.

Resumable, chunked uploads

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_MB bounds 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 in client/upload-entry.js (CHUNK_SIZE).
  • Folder structure. For each file the browser reads its webkitRelativePath and sends it (path minus filename) as the tus relativePath metadata, plus filename and the destination base folder. On completion the server recreates the tree under the user's vault root.
  • How resume works. tus tracks a per-upload byte offset; @uppy/tus stores the upload URL in localStorage. 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, Windows Thumbs.db/desktop.ini, …) are skipped before queueing and also rejected server-side, so they never silently consume vault storage. The list is configurable via UPLOAD_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.

Migration away from .zip uploads

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 .zip choice in the Import dialog and its client-side unpack path (runImport); the export feature still builds a .zip in the browser, so the zip helper (WOZip.zip) stays.
  • the old bulk POST /api/import multipart endpoint (it sent the whole folder in one request, which broke the 100 MB limit). Single small inline attachments still use POST /api/upload.
  • the unused adm-zip dependency. There was never a server-side unzip step — the server only ever held ciphertext — so nothing else changed on the backend.

Cloudflare / ingress notes

  • Each chunk request is < 100 MB, satisfying the Cloudflare body limit. Confirm the origin (nginx/ingress) allows it too: set client_max_body_size to 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, and Location. These are allow-listed in src/main.ts (enableCors) and advertised by the tus server; a stripping proxy in front would break resume.
  • TUS_TMP_DIR must be writable with room for the largest in-flight upload.

Continuous delivery

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.

License

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.


Generate encryption key and jwt secret

run the following command twice

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Stripe testing

https://docs.stripe.com/testing

About

websidian is an open-source, privacy-first E2E encrypted knowledge vault in any browser (Desktop & Mobile). Take notes, organize nested folders, sketch with Excalidraw and read PDFs, odt, docx files all online, with nothing to install. Everything is end-to-end encrypted in browser and the notes living on your connected storage.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages