Setup and Configuration

This guide covers installing, configuring, and running mdnest.

Running mdnest in an existing Kubernetes cluster instead — including active/active with the git-native HA topology — is covered in kubernetes.md. This guide is the Docker Compose install.


Prerequisites

There are two ways to install. They run the same images and give you the same mdnest.

Plain Docker Compose Guided setup
You get one docker-compose.yml you own and edit a script that writes the compose file from mdnest.conf
Images pulled from ghcr.io, nothing to build built from source on your machine
Good for people who run their own proxy, TLS and networks a one-command install with git sync, HTTPS and multi-user wired for you

Plain Docker Compose (or docker run)

No clone, no setup script, nothing to build. Four commands:

mkdir mdnest && cd mdnest
curl -fsSLo docker-compose.yml https://raw.githubusercontent.com/mahsanamin/mdnest/main/deploy/compose/docker-compose.yml
echo "MDNEST_PASSWORD=$(openssl rand -base64 18)"  > .env
echo "MDNEST_JWT_SECRET=$(openssl rand -hex 32)"  >> .env
docker compose up -d

Open http://localhost:3236 and sign in as admin with the password from .env (cat .env). If you forget a secret, docker compose up refuses to start and names the variable, so a half-configured mdnest never runs.

What is running: two containers, backend (the API) and frontend (nginx serving the web app and proxying /api/ to the backend). Only the frontend has a port. Your notes are plain .md files in ./notes on the host. The mdnest-secrets volume holds API tokens, preferences and stickies. It isn't notes, but it is state you'd miss.

The file is short and every optional setting is a comment in it: deploy/compose/docker-compose.yml. The usual changes:

I want to… Change this in docker-compose.yml
add a second notes folder another line under backend.volumes: - /srv/work:/data/notes/work (each mount is one namespace in the sidebar)
reach it from other machines ports: ["3236:80"] instead of 127.0.0.1:3236:80
use my own reverse proxy delete ports, attach frontend to your proxy's network, point the proxy at frontend:80, and set FRONTEND_ORIGIN to your public URL (the block at the end of the file shows the lines)
turn on the task board, drawings or slides uncomment ENABLE_TASK_BOARD, ENABLE_EXCALIDRAW or ENABLE_MARP
accounts for a team uncomment the multi-user block at the end of the file (adds Postgres)
pin a version replace :latest with a release, e.g. :4.5.3, on both images

Point your proxy at the frontend, never the backend. The frontend already routes /api/ and the /api/ws websocket.

Upgrade: docker compose pull && docker compose up -d. Your notes and settings stay where they are.

The same thing with docker run. The frontend finds the backend by the name backend, so both containers need a shared network, and the backend must be named backend on it:

docker network create mdnest
docker volume create mdnest-secrets

docker run -d --name backend --network mdnest --restart unless-stopped \
  -e MDNEST_USER=admin \
  -e MDNEST_PASSWORD='pick-a-strong-password' \
  -e MDNEST_JWT_SECRET="$(openssl rand -hex 32)" \
  -e NOTES_DIR=/data/notes -e SECRETS_DIR=/data/secrets \
  -e FRONTEND_ORIGIN=http://localhost:3236 \
  -e SERVER_ALIAS=mdnest \
  -v "$PWD/notes:/data/notes/notes" \
  -v mdnest-secrets:/data/secrets \
  ghcr.io/mahsanamin/mdnest-backend:latest

docker run -d --name mdnest-frontend --network mdnest --restart unless-stopped \
  -p 127.0.0.1:3236:80 \
  ghcr.io/mahsanamin/mdnest-frontend:latest

Every setting in the rest of this guide is an environment variable on the backend container, with the same name as in mdnest.conf. Only these keys are specific to the guided setup and don't apply here: MOUNT_* (use volumes), BACKEND_PORT, FRONTEND_PORT and BIND_ADDRESS (use ports), SSH_KEY_PATH and GIT_SYNC_INTERVAL (the git-sync sidecar), CADDY_DOMAIN, COMPOSE_PROJECT_NAME, and ENABLE_MCP / MCP_* (those configure the separate MCP server container, see mcp.md). The Firebase settings FIREBASE_SERVICE_ACCOUNT and FIREBASE_WEB_CONFIG are file paths, so mount the files into the backend and give the path inside the container.


Guided setup

# 1. Clone the repository
git clone https://github.com/mahsanamin/mdnest.git
cd mdnest

# 2. Run setup (creates mdnest.conf from the sample on first run)
./mdnest-server setup

# 3. Edit mdnest.conf with your settings
#    - Set your username and password
#    - Add MOUNT_ entries for your note directories
#    - Optionally configure git sync

# 4. Generate config and start
./mdnest-server rebuild

Open http://localhost:3236 in your browser and log in with the credentials you configured.


Configuration File: mdnest.conf

The mdnest.conf file is the single source of configuration. The setup.sh script reads it and generates both .env and docker-compose.yml.

On first run, setup.sh copies mdnest.conf.sample to mdnest.conf and exits, prompting you to edit it.

All Settings

Setting Default Description
MDNEST_USER admin Username for the login screen
MDNEST_PASSWORD changeme Password for the login screen. Change this.
MDNEST_JWT_SECRET changeme Secret key used to sign JWT tokens. Use a long random string.
FRONTEND_ORIGIN http://localhost:<FRONTEND_PORT> The full URL where the frontend is served. Used for CORS. Update this if you use a custom domain or reverse proxy.
BACKEND_PORT 8286 Host port mapped to the backend container (port 8080 internally)
FRONTEND_PORT 3236 Host port mapped to the frontend container (port 80 internally)
BIND_ADDRESS 127.0.0.1 Host IP(s) to bind the published ports to. Comma-separated for multi-IP (e.g. 127.0.0.1,100.73.118.115) so you can expose mdnest over a Tailscale / VPN address while keeping the LAN dark, without falling back to 0.0.0.0.
GIT_AUTHOR_NAME (none) Name used for git commits when git-sync is enabled
GIT_AUTHOR_EMAIL (none) Email used for git commits when git-sync is enabled
AUTH_MODE single Auth mode: single (file-based, no DB) or multi (Postgres-backed users & permissions)
POSTGRES_HOST postgres PostgreSQL host (only when AUTH_MODE=multi). Use postgres for the built-in container.
POSTGRES_PORT 5432 PostgreSQL port (only when AUTH_MODE=multi)
POSTGRES_DB mdnest PostgreSQL database name (only when AUTH_MODE=multi)
POSTGRES_USER mdnest PostgreSQL user (only when AUTH_MODE=multi)
POSTGRES_PASSWORD (none, required when multi) PostgreSQL password (only when AUTH_MODE=multi)
USER_PROVIDER local Identity provider when AUTH_MODE=multi: local (username/password + Postgres TOTP), firebase (see docs/firebase-setup.md), or sso (generic OIDC — see docs/sso-setup.md).
SSO_ISSUER_URL (required when USER_PROVIDER=sso) OIDC issuer URL, e.g. https://accounts.google.com, https://<org>.okta.com.
SSO_CLIENT_ID (required when USER_PROVIDER=sso) OAuth client ID from your IdP.
SSO_CLIENT_SECRET (required when USER_PROVIDER=sso) OAuth client secret from your IdP.
SSO_REDIRECT_URL <FRONTEND_ORIGIN>/api/auth/sso/callback Override if your callback URL doesn't match the default.
SSO_ALLOWED_DOMAINS (none) Comma-separated email-domain allowlist, e.g. example.com. Leave empty to allow any verified email.
SSO_PROVIDER_LABEL SSO Text on the sign-in button (e.g. Google, Okta).
FIREBASE_PROJECT_ID, FIREBASE_SERVICE_ACCOUNT, FIREBASE_WEB_CONFIG (required when USER_PROVIDER=firebase) See docs/firebase-setup.md.
ADMIN_EMAILS (none) Comma-separated emails auto-promoted to role=superadmin on every startup (idempotent — removals are NOT auto-demoted).
REQUIRE_2FA false Force TOTP enrollment on next login for all users (local + Firebase only — ignored in SSO mode).
ENABLE_LIVE_COLLAB false Enable WebSocket-based live collaboration: presence, cursors, comments, real-time tree updates. Multi mode only.
DEFAULT_THEME (v4.3.0+) auto Theme for users who have not chosen one: auto follows the viewer's operating system setting, dark and light pin it. A per-user choice is stored server-side and always overrides this, so changing it later only affects people who have never picked. Rejected at setup time if it is not one of the three values.
ENABLE_TASK_BOARD (v4.0.0+) false Enable the kanban task board over note checkboxes, including task relations, filters and the cross-workspace "All workspaces" scope. Routes are not registered and the UI chunk is not loaded when off.
ENABLE_MARP (v4.1.0+) false Render a note whose frontmatter declares marp: true as a slide deck in the Preview pane. The Marp engine chunk only downloads for a deck.
ENABLE_MARP_THEMES (v4.2.0+) false Requires ENABLE_MARP. Adds a shared theme catalog decks reference by name (theme: <name>) plus a superadmin Marp Themes admin tab. Themes live in the reserved .marp-themes namespace; setup.sh backs it with the mdnest-marp-themes named volume so a rebuild doesn't wipe them, and the Helm chart already mounts all of /data/notes.
ENABLE_EXCALIDRAW (v4.2.0+) false Open .excalidraw.md notes on an Excalidraw canvas and allow read-only drawing embeds in any note. The (large) editor bundle is code-split and only loads when a drawing is opened. See docs/excalidraw.md.
EXCALIDRAW_LIBRARIES (v4.2.0+) (none) Requires ENABLE_EXCALIDRAW. Comma-separated URLs to .excalidrawlib files, preloaded into every drawing so an organisation can ship a shared shape set. Fetched by the browser, not the backend, so the URLs must be reachable by clients (and CORS-enabled).
OIDC_GROUPS_CLAIM (v4.2.0+) (none) Requires USER_PROVIDER=sso. Name of the ID-token claim holding the user's IdP group IDs (e.g. groups on Entra ID). Enables OIDC-group membership in access Groups. Empty disables OIDC-group resolution entirely. Membership is snapshotted at login — see docs/security.md.
STORAGE_BACKEND (v4.0.0+) local local (filesystem; git history via the optional git-sync sidecar) or git (in-process git with idle-debounced commits, which also enables comment-marker recovery across a delete+recreate). Only these two values are accepted; anything else fails at startup.
GRANT_MAX_DEPTH 3 Cap on how deep into a namespace tree a grant's path can go (/ = depth 0). New grants beyond this depth return 400. Existing rows are grandfathered. Set 0 for no limit.
INSECURE_DEV_LOGIN false Dev-only. Enables POST /api/auth/dev-login which mints a session for any existing user by email — bypassing the IdP. Loud red warning pill renders on every authenticated page while this is on. NEVER enable on a non-localhost deployment.
COMPOSE_PROJECT_NAME (unset = directory basename) Override the docker compose project name. Useful when running parallel installs from differently-named (or similarly-named) directories so their containers don't collide.
SSH_KEY_PATH (none) Path to SSH private key on the host. Mounted into the backend for git pull via sync button. Must be passphrase-free.
CADDY_DOMAIN (none) Domain name for automatic HTTPS via Caddy. When set, adds a Caddy container and makes backend/frontend ports internal-only. Requires a DNS A record pointing to the server.
SERVER_ALIAS (none) Short name advertised by /api/config so the mdnest CLI can auto-pick the right @alias on mdnest login <url> <token>.
DISABLE_UPDATE_CHECK (v3.8.0+) false When true, the backend stops polling GitHub for newer mdnest releases. The "Update available" badge in the sidebar disappears. Useful for air-gapped or privacy-sensitive installs.
UPDATE_CHECK_REPO (v3.8.0+) mahsanamin/mdnest Owner/repo to poll for releases. Override if you've forked mdnest and want the badge to track your fork instead of upstream.
MOUNT_<name> (none, at least one required) Maps a namespace to a host directory. See below.

Example Configuration

# Auth
MDNEST_USER=ahsan
MDNEST_PASSWORD=a-strong-password-here
MDNEST_JWT_SECRET=another-long-random-string

# Frontend origin (for CORS)
FRONTEND_ORIGIN=http://localhost:3236

# Ports
BACKEND_PORT=8286
FRONTEND_PORT=3236

# Git sync
GIT_AUTHOR_NAME=Ahsan
[email protected]

# Namespace mounts
MOUNT_personal=/home/ahsan/notes/personal
MOUNT_work=/home/ahsan/notes/work

Namespaces

Namespaces are the top-level organizational unit in mdnest. Each namespace maps to a directory on the host machine.

How They Work

Every MOUNT_<name>=<host_path> entry in mdnest.conf creates a namespace. When setup.sh runs, it generates Docker volume mounts that map each host path into the container at /data/notes/<name>.

The backend scans /data/notes/ at runtime and exposes each subdirectory as a namespace through the API. The frontend displays them in the namespace selector dropdown.

Adding a Namespace

  1. Create the directory on your host (or point to an existing one):

    mkdir -p /home/ahsan/notes/projects
    
  2. Use the interactive command (handles everything: config, directory, git, deploy key):

    ./mdnest-server add-namespace
    

    It offers to reload for you at the end, which is the whole of step 3.

    Or manually add a MOUNT_ line to mdnest.conf:

    MOUNT_projects=/home/ahsan/notes/projects
    
  3. Apply it:

    ./mdnest-server reload
    

    A conf edit alone changes nothing. Namespaces are Docker volume mounts, so the running backend keeps serving the mounts it was created with until the containers are recreated — a namespace you have added but not applied is simply absent from the UI and the API. ./mdnest-server status compares mdnest.conf against what the running container actually serves and tells you when the two disagree, in either direction. Use rebuild instead of reload only when you also need the images rebuilt (reload is ~10s, rebuild ~60s).

Removing a Namespace

./mdnest-server remove-namespace

Lists all namespaces, asks which to remove, cleans up config and deploy key. Files on disk are NOT deleted.

Or manually: remove the MOUNT_ line from mdnest.conf and run ./mdnest-server reload. Until you do, the container still has the old mount and keeps serving the namespace — ./mdnest-server status flags that too.

Naming Rules

Namespace names (the part after MOUNT_) must be simple identifiers:


Multi-User Mode

By default, mdnest runs in single-user mode -- one user, file-based auth, no database needed. This is the simplest setup and works for personal use.

Multi-user mode adds PostgreSQL-backed user management with roles and namespace-level access control. When enabled, setup.sh automatically adds a Postgres container to docker-compose.yml.

Enabling Multi-User Mode (New Install)

Add these lines to mdnest.conf:

AUTH_MODE=multi
POSTGRES_PASSWORD=a-secure-password

# Optional -- defaults are fine for the built-in Postgres container:
# POSTGRES_HOST=postgres
# POSTGRES_PORT=5432
# POSTGRES_DB=mdnest
# POSTGRES_USER=mdnest

Then build and start:

./mdnest-server rebuild

On first startup, the backend automatically:

  1. Connects to PostgreSQL
  2. Creates the users and access_grants tables
  3. Seeds the initial admin user from MDNEST_USER / MDNEST_PASSWORD

Upgrading from Single to Multi-User

If you already have a running single-user mdnest and want to enable multi-user:

  1. Edit mdnest.conf -- add the AUTH_MODE and POSTGRES_PASSWORD lines shown above.

  2. Regenerate config:

    ./mdnest-server setup
    

    This regenerates docker-compose.yml with a Postgres service added.

  3. Run migrations:

    ./mdnest-server migrate
    

    This starts Postgres, connects the backend, and creates the database tables.

  4. Rebuild and start:

    ./mdnest-server rebuild
    

Your existing notes and configuration remain untouched. The database only stores user accounts and access permissions -- your notes are still plain files on disk.

For a small company deploying mdnest as a shared knowledge base, the recommended config is multi-user mode + corporate SSO + a TLS reverse proxy:

# Auth
AUTH_MODE=multi
USER_PROVIDER=sso
ENABLE_LIVE_COLLAB=true
GRANT_MAX_DEPTH=3
[email protected],[email protected]    # auto-promoted to superadmin

# Postgres
POSTGRES_PASSWORD=<long-random-string>

# OIDC (from your IdP — see docs/sso-setup.md)
SSO_ISSUER_URL=https://accounts.google.com
SSO_CLIENT_ID=<from-IdP>
SSO_CLIENT_SECRET=<from-IdP>
SSO_ALLOWED_DOMAINS=example.com
SSO_PROVIDER_LABEL=Google

# TLS via Caddy (built-in HTTPS)
CADDY_DOMAIN=notes.example.com
FRONTEND_ORIGIN=https://notes.example.com

# JWT secret — use a fresh long random value
MDNEST_JWT_SECRET=<long-random-string>

# Mounts (one per team / topic)
MOUNT_engineering=/srv/notes/engineering
MOUNT_design=/srv/notes/design
MOUNT_ops=/srv/notes/ops
./mdnest-server rebuild

What this gets you:

After the first sign-in, your ADMIN_EMAILS users are SuperAdmin. From the admin panel they can invite the rest of the team and assign per-namespace Admins.

Using an External PostgreSQL

If you prefer to use an existing Postgres server instead of the built-in container, set POSTGRES_HOST to your server's address:

AUTH_MODE=multi
POSTGRES_HOST=db.example.com
POSTGRES_PORT=5432
POSTGRES_DB=mdnest
POSTGRES_USER=mdnest
POSTGRES_PASSWORD=your-password

When POSTGRES_HOST is not postgres (the default), setup.sh does not add a Postgres container to docker-compose.yml -- it assumes you are managing the database yourself.


Git Sync

mdnest includes an optional git-sync sidecar container that automatically commits and pushes your notes to a remote git repository every 10 minutes.

Setting Up Git Sync

1. Initialize git in each notes directory:

cd /home/ahsan/notes/personal
git init
git remote add origin [email protected]:youruser/personal-notes.git
echo "# Personal Notes" > README.md
git add -A && git commit -m "init"
git push -u origin main

2. Set up SSH keys:

The git-sync container needs unencrypted SSH keys. Your regular SSH key likely has a passphrase and is decrypted by macOS Keychain or an SSH agent — neither is available inside the container.

Keys live in git-sync/keys/. The sync script resolves keys in this order:

  1. git-sync/keys/<namespace> — a per-namespace key (matches your MOUNT_<name>)
  2. git-sync/keys/default — a shared key used for all namespaces that don't have their own
  3. No key found — commits locally but skips push/pull

Option A: Single key for all repos (simplest if you have a machine user or personal key):

mkdir -p git-sync/keys
# Copy or generate an unencrypted key
ssh-keygen -t ed25519 -f git-sync/keys/default -N "" -C "mdnest-sync"

Add the public key to your GitHub account (Settings > SSH Keys) or as a collaborator key.

Option B: One key per namespace (required if using GitHub deploy keys, since each must be unique):

mkdir -p git-sync/keys
ssh-keygen -t ed25519 -f git-sync/keys/work_brain -N "" -C "mdnest-sync"
ssh-keygen -t ed25519 -f git-sync/keys/personal   -N "" -C "mdnest-sync"

Add each .pub key to the corresponding repo's deploy keys:

Why not mount ~/.ssh directly?

  • Passphrase-protected keys silently fail (no agent to decrypt them).
  • macOS SSH configs use UseKeychain, which Alpine's SSH doesn't recognize and treats as a fatal error.

3. Configure git identity in mdnest.conf:

GIT_AUTHOR_NAME=Your Name
[email protected]

4. Rebuild and start:

./mdnest-server rebuild

Git sync starts automatically when keys are found in git-sync/keys/. No keys = no sync — your notes stay local.

How It Works

The sync loop runs every 600 seconds (10 minutes) per namespace:

  1. Commit — stages all changes and commits. If the previous unpushed commit is also a sync commit, it squashes into it instead of creating a new one. This keeps history clean when push fails across multiple cycles.
  2. Pull — pulls from the remote with rebase for linear history.
  3. Push — pushes to the remote. If push fails, retries next cycle.

Important: Let mdnest Own the Repo

The git remote should be treated as a backup destination, not a shared workspace. Do not push to it from other tools or machines. Since mdnest is the only process committing and pushing, conflicts cannot occur under normal use.

If a conflict does happen (e.g., someone accidentally pushed to the repo directly), git-sync handles it automatically: it saves the local version as a .sync-conflict-* file, accepts the remote, and keeps the sync loop running. No data is lost, no manual intervention needed.


Git Pull from the Web UI (Sync Button)

If your namespaces are git repos managed outside mdnest (e.g., pushed from CI or other machines), the admin sync button in the sidebar lets you pull the latest changes without leaving the browser.

For this to work, the backend container needs an SSH key to authenticate with the git remote.

Generate a passphrase-free key specifically for mdnest:

ssh-keygen -t ed25519 -f ~/.ssh/mdnest-deploy -N "" -C "mdnest-sync"

Add the public key to your git provider:

Then add to mdnest.conf:

SSH_KEY_PATH=/home/you/.ssh/mdnest-deploy

Rebuild:

./mdnest-server rebuild

The sync button (↻ in the sidebar) will now pull from the remote.

Option B: Use your existing SSH key

If your default SSH key (~/.ssh/id_ed25519 or ~/.ssh/id_rsa) has no passphrase, you can use it directly:

SSH_KEY_PATH=/home/you/.ssh/id_ed25519

Important: If your key has a passphrase (common on macOS with Keychain), it will not work inside the Docker container — there's no SSH agent to decrypt it. Use Option A instead.

Option C: No SSH key (manual pull on host)

If you don't set SSH_KEY_PATH, the sync button will still:

But it won't pull from the remote. You'll need to run git pull on the host machine manually or via a cron job:

# Add to crontab: pull every 5 minutes
*/5 * * * * cd /path/to/your/notes && git pull --ff-only 2>/dev/null

Remote Access

By default, mdnest binds to 127.0.0.1 and is only accessible from the host machine. To access it from other devices, choose one of the following approaches.

Option 1: Caddy (Built-in, Simplest)

mdnest includes built-in HTTPS support via Caddy. Caddy runs as a Docker container alongside the app and automatically provisions TLS certificates from Let's Encrypt.

1. Point a DNS A record at your server's public IP:

notes.yourdomain.com → 203.0.113.10

2. Add to mdnest.conf:

CADDY_DOMAIN=notes.yourdomain.com
FRONTEND_ORIGIN=https://notes.yourdomain.com

3. Rebuild and start:

./mdnest-server rebuild

Caddy listens on ports 80 and 443. HTTP requests are automatically redirected to HTTPS. The backend and frontend ports are no longer exposed to the host -- all traffic flows through Caddy.

Note: Ports 80 and 443 must be open in your firewall / security group. Port 80 is required for Let's Encrypt HTTP-01 challenge validation.

Option 2: Tailscale Serve

If you use Tailscale, expose the frontend port:

tailscale serve --bg 3236

This gives you a https://your-machine.tailnet-name.ts.net URL accessible from any device on your Tailscale network. No additional configuration is needed.

Option 3: Nginx Reverse Proxy + Certbot

Install nginx and certbot on the host, then create a proxy configuration:

server {
    server_name notes.yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1:3236;
        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;
    }
}

Obtain a TLS certificate:

sudo certbot --nginx -d notes.yourdomain.com

Update FRONTEND_ORIGIN in mdnest.conf to https://notes.yourdomain.com, then re-run ./mdnest-server rebuild.

Option 4: Cloudflare Tunnel

cloudflared tunnel create mdnest
cloudflared tunnel route dns mdnest notes.yourdomain.com
cloudflared tunnel --url http://127.0.0.1:3236 run mdnest

Update FRONTEND_ORIGIN in mdnest.conf to https://notes.yourdomain.com, then re-run ./mdnest-server rebuild.


Environment Variables

These environment variables are set in the generated .env file and consumed by the Docker containers. You should not edit .env directly -- edit mdnest.conf and run ./mdnest-server rebuild instead.

Variable Description
MDNEST_USER Login username
MDNEST_PASSWORD Login password
MDNEST_JWT_SECRET Secret for signing JWT tokens
FRONTEND_ORIGIN URL where the frontend is served (used for CORS headers)
GIT_AUTHOR_NAME Name for git-sync commits
GIT_AUTHOR_EMAIL Email for git-sync commits
AUTH_MODE Auth mode: single or multi
DEFAULT_THEME Theme for users who have not chosen one: auto, dark or light
NOTES_DIR Path to the notes root inside the container (set to /data/notes in docker-compose.yml)
PORT Backend listen port inside the container (defaults to 8080)
POSTGRES_HOST PostgreSQL host (multi mode only)
POSTGRES_PORT PostgreSQL port (multi mode only)
POSTGRES_DB PostgreSQL database (multi mode only)
POSTGRES_USER PostgreSQL user (multi mode only)
POSTGRES_PASSWORD PostgreSQL password (multi mode only)

Two-Factor Authentication (2FA)

Enabling 2FA

Add to mdnest.conf:

REQUIRE_2FA=true
TOTP_ISSUER=YourOrg mdnest    # Name shown in authenticator app (optional)

Run ./mdnest-server rebuild. All users will be required to set up 2FA on their next login.

Sharing 2FA Across Multiple Servers

If you run multiple mdnest instances (e.g. growth.mdnest.yourorg.com, docs.mdnest.yourorg.com), users can use the same authenticator entry for all servers.

Set up 2FA on the first server, then export and import the secret:

# On server A (where 2FA is already set up)
./mdnest-server export-2fa ahsan

# Output:
#   TOTP Secret: JBSWY3DPEHPK3PXP
#   To import on another server:
#     ./mdnest-server import-2fa ahsan JBSWY3DPEHPK3PXP

# On server B, C, D...
./mdnest-server import-2fa ahsan JBSWY3DPEHPK3PXP

The same 6-digit code from the authenticator app now works on all servers. Use the same TOTP_ISSUER on all servers so the authenticator app groups them.

Admin: Reset a User's 2FA

From the admin panel in the web UI, or via API:

curl -X POST https://your-server/api/admin/reset-2fa \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"userId": 5}'

The user will need to set up 2FA again on next login (if REQUIRE_2FA=true).

Updating

To update mdnest to the latest version:

cd mdnest
./mdnest-server update

Troubleshooting

Empty namespace / no files showing up

Docker Desktop file sharing (macOS/Windows)

Docker Desktop requires explicit file sharing permissions for host directories. If your mounted directories appear empty inside the container:

  1. Open Docker Desktop settings.
  2. Go to Resources > File Sharing.
  3. Add the parent directory of your notes folders.
  4. Restart Docker Desktop and re-run ./mdnest-server rebuild.

Port conflicts

If port 8286 or 3236 is already in use, change BACKEND_PORT or FRONTEND_PORT in mdnest.conf and re-run ./mdnest-server rebuild.

"invalid credentials" after changing password

After changing MDNEST_PASSWORD in mdnest.conf:

  1. Re-run ./mdnest-server rebuild.
  2. Clear your browser's local storage for the mdnest site (the old JWT token is no longer valid).

git-sync not pushing

Container keeps restarting

Check the logs for the failing container:

./mdnest-server logs backend
./mdnest-server logs frontend

Common causes:

Backend fails to start with "failed to connect to database"

This happens when AUTH_MODE=multi but Postgres is not reachable: