Skip to content

Self-Hosting Glassy

Self-hosting runs Glassy on your own machine as a single-user personal appliance. This is the recommended path if you want live Obsidian vault sync, Ollama, Agent Gateway, or full data sovereignty — all features that require the server to reach 127.0.0.1 on your machine.

The cloud-hosted Glassy (at app.glassy.fyi) cannot reach your localhost. Self-hosting solves it by running the server where those services live.

Get Glassy: PWA, browser companion, and the Docker bring-up commands

The real Get Glassy screen, including the Docker commands.

Quick start

Self-hosting is free with any Glassy account — Free included. Create an account at www.glassy.fyi before continuing (Clear members can use their Clear account). The appliance starts and creates your local admin immediately; the email and self-host token are re-checked about every 30 days. A failed check leaves the appliance running in a clearly reported degraded state; it does not lock you out of your own data.

git clone https://github.com/0Reliance/glassy-selfhost.git
cd glassy-selfhost
cp .env.example .env
# Edit .env — fill in six required fields:
#   GLASSY_TAG=v2.58.0
#   GLASSY_MEMBER_EMAIL=your@glassy-account-email
#   GLASSY_SELFHOST_TOKEN=<pairing token from Settings → Self-hosting>
#   GLASSY_VERIFY_CLOUD_URL=https://clear.glassy.fyi   # for Clear members (app.glassy.fyi for other accounts)
#     # The token prevents anyone who merely knows your email from
#     # spinning up an instance against your account.
#   JWT_SECRET=$(openssl rand -hex 32)
#   API_KEY_ENCRYPTION_KEY=$(openssl rand -hex 32)
docker compose up -d

On first boot the appliance verifies your pairing token against the cloud, creates your local account using the email on that Glassy account, and prints the initial password once:

docker compose logs glassy | grep -A2 "Default admin created"

Alternatively, the initial password is written to a file inside the container that survives recreation and is deleted after your first password change:

docker exec glassy cat /app/data/.initial_admin_password

Sign in at http://localhost:3000 with your Glassy account email and that password. The password change is forced on first login — you are immediately prompted to set a permanent password before you can use the workspace. Registration is permanently disabled — this is a single-owner personal appliance. All premium features are unlocked automatically.

If you are scripting the install (or handing it to an AI agent)

The generated password is not the only thing standing between a fresh container and a working API. The seeded owner carries password_must_change, and the server refuses every authenticated /api/* call until it is cleared:

403 {"error":"This account must change its password before it can use the API.",
     "code":"PASSWORD_CHANGE_REQUIRED","password_must_change":true}

POST /api/auth/login is not behind that wall. So a script logs in, receives a perfectly good token, and then gets 403 from /api/notes — which reads exactly like a broken install. It is not broken; it is one call away from working. The sequence, in order:

# 1. The generated password exists for exactly one purpose, and only until step 3.
docker exec glassy cat /app/data/.initial_admin_password

# 2. Log in — this succeeds even with the flag set.
curl -s -X POST http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"<from the seed file>","password":"<from the seed file>"}'   # -> { token, user }

# 3. Clear the wall. Both fields are required; the new password must be at least
#    8 characters and contain an uppercase letter, a lowercase letter and a digit.
curl -s -X POST http://localhost:8080/api/auth/change-password \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"currentPassword":"<seed>","newPassword":"<yours>"}'

Four details that otherwise cost an hour:

  • Read the body, not the status. 200 {"message":"Password updated successfully."} means done; 400 means a field is missing or the new password is too weak; 401 means unauthenticated; and 403 from this endpoint means the current password you sent was wrong — a different 403 from the wall, and it carries no code field.
  • Your existing token stays valid after the change. Nothing is revoked, so no re-login is required. Re-login anyway if you want a clean identity, but nothing forces it.
  • The seed file is deleted by step 3. Capture it once, before changing anything; a recovery script cannot re-read it afterwards.
  • You do not have to wait for the wall to drop. The user row is cached for 15 seconds, which would otherwise keep returning PASSWORD_CHANGE_REQUIRED after a successful change; the route invalidates that cache, so the clear takes effect immediately.

This is also why smoke-test credentials need a specific account

A post-deploy smoke test that logs in and then calls an authenticated endpoint must use an account whose password_must_change is already cleared. Using the seeded owner gets a token successfully and then fails every authenticated check on 403 — a broken check reporting a failure against a healthy deploy, which is worse than no check, because it trains whoever reads it to ignore the failure.

How your account works (and what the cloud sees)

The self-hosted appliance and the cloud are two separate systems that share only one thing: your email address. No passwords, tokens, or note content ever leave your machine.

At boot (every 30 days): The appliance sends your email and a pairing token you control — and nothing else (no password, no note content, no session data) — to app.glassy.fyi. The cloud verifies that token for any Glassy account, Free included, and replies with a simple yes/no. No password is ever exchanged. The result is cached locally for 30 days, so the appliance works offline within that window.

First boot only: If no account exists yet, the appliance creates a local admin account using the email from your Glassy account. A random password is generated, printed to the logs once, and stored only as a bcrypt hash — the plaintext is never saved. Sign in with it, then set a permanent password — the password change is forced on first login before you can use the workspace.

Every login after that: 100% local. The appliance authenticates you against its own SQLite database using a JWT signed with your JWT_SECRET. No cloud call happens during login — the cloud only confirmed your membership at boot, and it only ever saw your email and a pairing token you control.

Your email + pairing token → Cloud: "account valid?" → Yes (cached 30 days)
                                                  ↓
First boot → Create local admin (random password, printed once)
                                                  ↓
Every login → Local JWT auth → No network, no phone-home

What works differently from cloud

Capability Cloud (app.glassy.fyi) Self-hosted
Notes, AI, capture, Glassy Capture Yes Yes
Live Obsidian vault sync No (server cannot reach your localhost) Yes
Ollama local AI No Yes
Agent Gateway (OpenClaw, Hermes) No (requires localhost) Yes
MCP server Yes — 37 tools, single-key, tier budgets Yes — all 42 tools, named keys, no budget
Second Brain corpus pane No (operator-run corpus) Yes
Every ability unlocked Every account; publishing needs payment Yes — owner, no quota
Registration Open / admin-controlled Permanently disabled
Commerce / upgrade funnel Yes Not present
Transactional email Yes Disabled (nothing leaves the machine)
Data location Cloud VM Your machine (glassy-data volume)

Configuration

Required

Variable Description
GLASSY_MEMBER_EMAIL Your Glassy account email. The appliance verifies the pairing token against the cloud on first boot (cached 30 days).
GLASSY_SELFHOST_TOKEN Required. Pairing token from Settings → Self-hosting → Generate token, generated by the account that owns the appliance (see GLASSY_VERIFY_CLOUD_URL below). Prevents unauthorized use of your account by someone who merely knows your email.
GLASSY_VERIFY_CLOUD_URL Where the appliance verifies email + token. Set it to the instance your account lives on — https://clear.glassy.fyi for Clear accounts, https://app.glassy.fyi for every other account. The token is verified against that same instance, so it must be minted there too.
JWT_SECRET Session token signing key. Generate with openssl rand -hex 32.
API_KEY_ENCRYPTION_KEY Encrypts stored API keys. Generate with openssl rand -hex 32.
GLASSY_TAG Image tag to pull from GHCR — required (set in .env): must name a released version (e.g. v2.58.0), never latest (the hosted build omits self-host features).

Set in compose (no action needed)

Variable Default Description
INSTANCE_ID self_hosted Activates single-user gating — do not change.
DEPLOYMENT_LOCALITY local Tells the app it's running locally. Hides the cloud-limitation banner in Obsidian settings.
OBSIDIAN_HOST_OVERRIDE host.docker.internal Rewrites 127.0.0.1 to the Docker host gateway so the containerized server can reach Obsidian on the host.
OBSIDIAN_NETWORK_ALLOWLIST — Additional comma-separated hostnames/IPs allowed for Obsidian connections. Use when Obsidian is on a LAN IP, Tailscale node, or WSL2 bridge address not covered by the defaults. Self-hosted only.
ENABLE_MCP_SERVER true Enables MCP server and Second Brain features.
ENABLE_AGENT_GATEWAY true Enables Agent Gateway (OpenClaw / Hermes).

Optional

Variable Default Description
APP_PORT 3000 Host port Glassy listens on. Change if port 3000 is in use — then update APP_URL + CORS_ORIGINS to match.
APP_URL http://localhost:3000 Base URL. Set this if you access via Tailscale or a domain.
CORS_ORIGINS http://localhost:3000 Must include every origin you use (Tailscale hostname, LAN IP, domain).
OLLAMA_BASE_URL http://host.docker.internal:11434 Ollama on the host (reachable via Docker host gateway). Use http://ollama:11434 with the bundled sidecar overlay.
OLLAMA_MODEL llama3.2 Default Ollama model when none is selected in-app.
BACKUP_ENCRYPTION_KEY — Encrypt on-demand backups (AES-256-GCM). Generate with openssl rand -hex 32; store the key off-machine.
CLUSTER_WORKERS min(2, CPUs−1) Worker processes. Raise on a multi-core host; set to 1 on a small box.

Cloud AI keys (GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY) are not read from .env on the appliance. Add your own keys in-app at Settings → API Keys (BYOK) — they are stored encrypted per-account in the database.

Local AI (Ollama)

Ollama on the host is auto-detected via host.docker.internal:11434 — no config needed if Ollama is already running. To use the bundled Ollama sidecar (nothing to install on the host):

docker compose -f docker-compose.yml -f docker-compose.ollama.yml up -d
docker compose -f docker-compose.yml -f docker-compose.ollama.yml \
  exec ollama ollama pull llama3.2

The sidecar supports NVIDIA GPUs — uncomment the deploy block in docker-compose.ollama.yml after installing the NVIDIA Container Toolkit.

Obsidian Vault Sync

Glassy can connect to your Obsidian vault via the Local REST API plugin. There are three connection methods:

On Windows with Docker Desktop (WSL2), the container cannot reach Obsidian on the Windows host's 127.0.0.1. The Glassy Capture browser extension solves this by proxying Obsidian requests from the server through your browser, which runs on the same machine as Obsidian.

Requires extension v2.15.0+ — earlier versions held the SSE in the MV3 service worker (Chrome evicts after ~30s), and v2.12.0–v2.13.0 lacked localhost host permissions for the self-host Glassy server URL. v2.14.0+ uses the offscreen document (persistent, never evicted) and broadens optional_host_permissions to cover any localhost port. v2.15.0 adds a chrome.alarms heartbeat (survives SW eviction) that recreates the offscreen doc after 2+ missed heartbeats.

Setup:

  1. Install the Obsidian Local REST API plugin (v4.0+) in Obsidian. Copy the API key.
  2. Install the Glassy Capture extension (Chrome or Firefox) — v2.15.0 or later.
  3. Sign in to the extension with your Glassy account (same email as your self-hosted appliance). Set the extension's Server URL to your self-host Glassy address (e.g. http://localhost:3000).
  4. Open the extension popup → Settings → Obsidian Bridge:
  5. Set Obsidian URL to http://127.0.0.1:27123 (HTTP avoids self-signed cert issues; HTTPS is https://127.0.0.1:27124).
  6. Paste the API Key from the Obsidian plugin.
  7. Toggle Obsidian Bridge on. Chrome will prompt for permission to access localhost — click Allow. This grants the extension host permission to reach both the Obsidian URL and the Glassy server URL (both are localhost). If you deny it, the popup shows a warning banner and the bridge won't connect.
  8. Click Test Connection — this tests the FULL bridge loop (server → extension → Obsidian), not just extension→Obsidian. A green result confirms both legs work.
  9. Click Save.
  10. In the self-hosted web app (Settings → Obsidian), you should see "✓ Extension bridge active — Obsidian connected." The URL/token fields are hidden on self-hosted because the extension manages them.

How it works: The extension maintains a persistent SSE connection to the Glassy server (via the offscreen document, which Chrome does not evict). When the server needs Obsidian data (AI context, vault browsing, search), it pushes a request to the extension, which calls Obsidian on 127.0.0.1:27124 directly and returns the result. The extension holds the API key locally — it is never sent to the server.

Server v2.35.0-beta.9+ recommended

beta.9 fixes a server-side SSE auth bug where the one-time ticket was consumed twice (once by each of two overlapping routers), causing 401 Invalid or expired SSE ticket in logs. The extension masked it by silently falling back to the less-secure ?token=<JWT> URL form — so the bridge still worked, but your JWT was leaking into proxy/access logs. Update the server image to beta.9+. No extension or Obsidian configuration change is required.

Verify the connection: curl -H "Authorization: Bearer <jwt>" http://localhost:3000/api/ext/obsidian-bridge/status should return {"connected":true,...}.

Tip

The extension's Test Connection button tests the full bridge loop. If it shows green with "plugin v4.x", both the SSE bridge to the server AND the extension→Obsidian fetch are working. If it shows green but warns "SSE bridge to server is not connected", toggle the bridge off and on to re-establish the SSE connection.

See the Obsidian integration guide for full details.

2. Direct Server → localhost (Linux/macOS)

On native Linux/macOS or Docker on Linux, the server reaches Obsidian directly via host.docker.internal:27124. This works out of the box with the default OBSIDIAN_HOST_OVERRIDE=host.docker.internal.

3. Network Allowlist (split-machine setups)

If Obsidian runs on a different machine than Glassy, set OBSIDIAN_NETWORK_ALLOWLIST to the Obsidian host's IP or hostname:

OBSIDIAN_NETWORK_ALLOWLIST=192.168.1.10,glassy.tail-net.ts.net

You'll also need to configure the Obsidian plugin to bind to 0.0.0.0 instead of 127.0.0.1, and use http://<obsidian-host-ip>:27123 as your Obsidian URL in Glassy settings.

Warning

Binding the Obsidian plugin to 0.0.0.0 exposes it to your entire network. Only do this on a trusted network or behind a firewall. The browser extension bridge (option 1) is safer because it doesn't expose Obsidian to the network at all.

Multi-device access

To use Glassy from another device (phone, laptop), set APP_URL and CORS_ORIGINS to your network URL and choose one of:

  • Tailscale (recommended) — WireGuard mesh VPN, no port forwarding, no public exposure. APP_URL=http://<machine>.tail-net.ts.net:3000
  • Cloudflare Tunnel — public HTTPS URL through Cloudflare's edge. APP_URL=https://glassy.example.com
  • Caddy HTTPS overlay — if you own a domain, docker compose -f docker-compose.yml -f docker-compose.https.yml up -d gives auto-renewing Let's Encrypt TLS.

See the full instructions in deploy/selfhost/README.md.

Cloud Sync (cross-instance data sync)

Cloud Sync keeps your notes, documents, folders, bookmarks (collections + highlights), voice recordings, conversations, pinned tags, and custom backgrounds synchronized between your self-hosted appliance and your Glassy cloud account. It runs over a server-to-server delta channel authenticated by a per-peer sync token (separate from your self-host pairing token). Since beta.40 it also transfers media files — note images, custom-background renditions, and voice audio — not just rows; see the Cloud Sync feature page for the health panel, media-missing signal, and recovery tools.

Enabling Cloud Sync

  1. Generate a sync token on the receiving side. Sign in to the instance you are syncing to — the cloud account for appliance-to-cloud sync, or your private VPS instance for a Pair — open Settings → Cloud Sync, and click Generate. Copy the raw token; it is shown once.
  2. Save it on the sending appliance. Open the appliance's Settings → Cloud Sync, paste the token into Pair token, set Syncs to to the receiver URL, and choose Save Cloud Sync settings. Both values resolve per cycle, with no restart.
  3. For a scripted/bootstrap install only: set GLASSY_SYNC_TOKEN=<token> in .env and restart. This is the recovery path; a token saved in Settings wins from its next cycle.
  4. Verify in the self-hosted web app at Settings → Cloud Sync — you should see the peer connection, credentialSource, per-type cursor status, and a Sync now button.

Scheduler tuning

Variable Default Purpose
GLASSY_SYNC_INTERVAL_MS 300000 (5 min) Full cycle interval — pulls peer changes + pushes local. Set to 0 to disable automation (manual Sync now still works).
GLASSY_SYNC_CHECK_MS 10000 (10 s) Fast-check interval — detects near-instant pushes from the peer without waiting for the full cycle.

Per-type, direction, and conflict controls

Each side of the pairing speaks for itself. The controls that render in your panel govern this side — and since 2.46.0 the receiving side honors its own persisted settings when it applies what arrives, so every control you see is real.

In Settings → Cloud Sync you can:

  • Enable/disable sync per content type (notes, documents, bookmarks, voice, conversations, pinned tags) — your side's toggles govern what it applies on arrival and what it sends.
  • Set direction (appliance side only): two-way (default), push-to-cloud, or pull-from-cloud — cycles run from the appliance, so direction lives there; the cloud panel states that boundary and where the control is.
  • Set conflict policy per side: most-recent (last-writer-wins, default) or cloud-wins — the policy of the side that applies the change decides the outcome.

Multi-appliance

Each appliance connects to the cloud independently with its own token. The architecture supports multiple appliances syncing to the same cloud account — each peer is tracked separately in the sync health dashboard.

Troubleshooting Cloud Sync

  • Panel shows "no peer connected" — confirm a Pair token is stored in Settings or GLASSY_SYNC_TOKEN is set. Check credentialSource and logs: docker compose logs glassy | grep -i sync.
  • Sync seems stuck — the is_applying flag can stick on if the process is interrupted. A full cycle runs every 5 minutes and self-corrects. Use Sync now to force a cycle.
  • Conflicts on the same note — with most-recent policy, the newer timestamp wins. A 3-way merge snapshot is preserved so you can recover overwritten content. Switch to cloud-wins if you want the cloud to always be authoritative.

Upgrading

docker compose pull
docker compose up -d

Database migrations apply automatically on start. To auto-update daily without manual intervention, use the Watchtower overlay:

docker compose -f docker-compose.yml -f docker-compose.watchtower.yml up -d

Backups

Glassy takes an automatic daily backup at 02:00 into /app/data/backups (inside the glassy-data volume, ~7 days retained). No setup required.

See Backups and Data Management for encryption, CLI backup, restore, and volume snapshot instructions.

Security

  • Change the admin password immediately after first login.
  • Private mode is enforced automatically — anonymous visitors are always redirected to login.
  • Firewall — if exposing beyond localhost, restrict access with UFW or equivalent.
  • Updates — pull regularly to get security patches.

Image source

Images are published to ghcr.io/0reliance/glassy-dash by GitHub Actions. Tag semantics matter: a versioned tag (v2.58.0) is the self-host release — built with the full self-host feature flags. The floating :latest and :main tags are hosted builds of main (they omit the self-host UI flags by design), and :selfhost-main is a floating self-host build of main for testers who want unreleased changes. Always pin a versioned tag:

GLASSY_TAG=v2.58.0 docker compose up -d

Every build verifies its baked variant marker before publishing, so a wrong-variant image cannot ship silently.

Troubleshooting

Port 3000 already in use

Set APP_PORT=3001 in .env and update APP_URL + CORS_ORIGINS to match:

APP_PORT=3001
APP_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:3001

Then docker compose up -d. The container always listens on 8080 internally; APP_PORT only changes the host-side mapping.

Checking container health

curl http://localhost:3000/ready
# Returns JSON: {"status":"ready",...} when healthy

If you get HTML instead of JSON, the container may still be starting up — wait a few seconds and retry.

Debugging from inside the container

The runtime image includes curl for network diagnostics:

docker compose exec glassy curl -s http://host.docker.internal:11434/api/tags | head -c 200

Container won't start

docker compose logs glassy

Common causes: missing JWT_SECRET / API_KEY_ENCRYPTION_KEY, or membership verification failed (check for ❌ lines in the log).

Obsidian Bridge cycling every ~60 seconds

If the bridge status flips between connected and disconnected roughly every minute, update to glassy-dash v2.36.0-beta.19+ and Glassy Capture v2.18.1+.

The root cause was a server-side race condition in obsidianBridgeRegistry.js: when the extension reconnected, the server closed the old SSE and registered the new one, but the old SSE's asynchronous req.on('close') handler fired after the new connection was registered and blindly deleted it. The registry showed disconnected even though the new SSE was alive, so the bridge reconnected again ~60s later — repeating the cycle. The fix tags each connection with a unique connectionId and only unregisters when the IDs match. The extension-side companion fix moved the heartbeat from setInterval (dies on service-worker eviction) to chrome.alarms (survives eviction). See the Obsidian Vault guide for the full diagnosis.

This is not a Chrome MV3 offscreen-eviction issue — the WORKERS offscreen reason has no built-in lifetime restriction. The real bug was the server-side async close-handler race, not a client-side lifecycle problem.

For the full troubleshooting guide, see SELF_HOSTED_DEPLOYMENT.md.