A self-hosted personal start page β new-tab replacement for your browser.
Clock, ambient effects, wallpapers, configurable search, bookmarks, collaboration, multi-user with admin panel.
No cloud. No tracking. Runs entirely on your own machine or server.
This project is 100% vibecoded β designed and built in conversation with AI (Claude Code), iterated feature by feature on a homelab. Craftsmanship for the joy of it, not for scale.
π¬ Questions, ideas, help? Join the Discord β https://discord.gg/2S9v5MKfGg
The home β clock, search, your wallpaper (image or video), rain effect:
The hub (!menu in the search bar) β liquid-glass tiles over your background:
The login:
Type !bm in the search bar to open the bookmark manager.
Type !h to search your hidden bookmarks.
Type !menu (or your configured bang) for the full-screen hub.
| Feature | Details |
|---|---|
| Clock & date | Large italic clock, ISO week number |
| Wallpapers | Images & videos, pin favorite, random rotation, adaptive light/dark theme via luminance sampling |
| Ambient effects | Rain and dust canvas animations (opt-in per-user) |
| Search | DuckDuckGo by default β Google, Brave, Bing, Kagi, or custom URL; per-user "open in new tab" option |
| Bangs | !bm bookmarks, !h hidden bookmarks, !g Google, !yt YouTube, !gh GitHub, !hub full menu, + all DDG bangs |
| Bookmarks | Collections with a nested folder tree, tags, import/export Netscape format (Chrome/Firefox/Safari/Edge), mobile bookmarklet; bookmarks can be hidden (excluded from search, accessible via !h) |
| Custom branding | Site name and favicon URL configurable from Admin β Settings; applied even before login |
| Collaboration | Share collections with individual users or whole groups β view / edit / manage permission levels, "shared" indicators, optional email notification |
| Roles & permissions (RBAC) | Bitwarden-style roles over a granular permission catalog; seeded owner / admin / user plus custom roles; a user can hold several roles (effective permissions = union) |
| Groups / teams | Bundle users into groups and grant a whole team access to a collection |
| TOTP / 2FA | RFC 6238, server-generated QR code, secrets encrypted at rest β enforced for invited / email-verified signups |
| Multi-user | First account created becomes the instance owner automatically |
| Invitations | Admin invites by email (with delivery confirmation) or by copying the invite link β works without SMTP; open registration toggle |
| SSO / OIDC | OpenID Connect (Authentik, Keycloak, Authelia, Googleβ¦) β JIT account provisioning |
| Admin panel | User management, storage quotas, upload limits, SMTP test, audit log, pending registrations, custom branding |
| Account setup & invitation emails; SMTP supports implicit TLS (port 465) and STARTTLS (port 587); configurable via env or admin UI | |
| Audit log | All security and admin actions logged and attributed to users; GDPR-compliant (no personal content logged, metadata preserved on account deletion) |
Docker (Compose recommended) β that's it. A prebuilt multi-arch image
(linux/amd64, linux/arm64) is published to GHCR, so nothing is compiled on
your machine:
ghcr.io/darktweek/cairn:latest # or pin a version, e.g. :0.2.3 (no "v")
docker run -d --name cairn -p 8080:8080 \
-e CAIRN_BASE_URL=http://localhost:8080 \
-e CAIRN_SESSION_SECRET="$(openssl rand -base64 32)" \
-v cairn_data:/data \
ghcr.io/darktweek/cairn:latestOpen http://localhost:8080, click Sign in β Register β the first account becomes the instance owner. That's it.
Create a compose.yaml:
services:
cairn:
image: ghcr.io/darktweek/cairn:latest
container_name: cairn
restart: unless-stopped
ports:
- "8080:8080" # remove when behind a reverse proxy
environment:
CAIRN_BASE_URL: "http://localhost:8080" # your public URL in production
CAIRN_SESSION_SECRET: "change-me" # openssl rand -base64 32
volumes:
- cairn_data:/data
volumes:
cairn_data:docker compose up -dNo SMTP? You can run entirely without it. Create your owner account from the Register link (open registration is on by default and auto-disables once that first account exists). To add more users, go to Admin β Invitations and copy the invite link shown right after creating one β no email needed. SMTP only delivers invitations and password resets automatically.
Pin a version with :0.2.7 instead of :latest (image tags are semver, no v
prefix). See the Configuration reference for all
environment variables.
Install the New Tab Redirect extension and point it to http://localhost:8080.
Use New Tab Homepage or set it in about:preferences under Home.
Settings β General β New tabs open with β Homepage β set your URL.
All configuration is via environment variables.
Sensitive values go in .env (already gitignored), or via Docker Secrets (see below).
| Variable | Default | Required | Description |
|---|---|---|---|
CAIRN_ADDR |
:8080 |
no | Listen address |
CAIRN_ENV |
production |
no | production or development |
CAIRN_BASE_URL |
β | yes | Your public URL, e.g. https://start.example.com |
CAIRN_DB_PATH |
/data/db.sqlite |
no | SQLite database path |
CAIRN_MEDIA_PATH |
/data/media |
no | Wallpaper storage directory |
CAIRN_SESSION_SECRET |
β | yes | HMAC key, minimum 32 characters |
| Variable | Default | Description |
|---|---|---|
CAIRN_DEFAULT_WALLPAPER_LIMIT |
10 |
Max wallpapers per user |
CAIRN_MAX_UPLOAD_SIZE |
52428800 |
Max single file size in bytes (50 MB) |
CAIRN_STORAGE_QUOTA |
209715200 |
Max total media per user in bytes (200 MB) |
Per-user overrides for both limits are available in the admin panel.
| Variable | Default | Required | Description |
|---|---|---|---|
CAIRN_SMTP_HOST |
β | yes | SMTP server hostname |
CAIRN_SMTP_PORT |
587 |
no | SMTP port |
CAIRN_SMTP_USER |
β | yes | SMTP username |
CAIRN_SMTP_PASS |
β | yes | SMTP password |
CAIRN_SMTP_FROM |
β | yes | Sender address |
CAIRN_SMTP_TLS |
true |
no | Enable TLS (port 465 = implicit TLS/SMTPS; other ports = STARTTLS) |
SMTP can also be configured entirely from the admin UI if not set via environment.
Use the Test button in Admin β Settings β SMTP to verify delivery before sending real invitations.
| Variable | Default | Description |
|---|---|---|
CAIRN_OIDC_ISSUER |
β | OIDC issuer URL. If set, locks SSO config (otherwise editable in admin) |
CAIRN_OIDC_CLIENT_ID |
β | OIDC client ID |
CAIRN_OIDC_CLIENT_SECRET |
β | OIDC client secret (put in .env) |
CAIRN_OIDC_PROVIDER_NAME |
SSO |
Label shown on the "Sign in with β¦" button |
CAIRN_OIDC_SCOPES |
openid profile email |
Requested scopes |
Redirect URI to register with your provider:
<CAIRN_BASE_URL>/api/auth/sso/callback
Provisioning: accounts are created automatically on first SSO login (JIT). Any user who can authenticate with your provider will get a Cairn account β restrict access at the provider level (groups, policies) if needed. Existing accounts are matched by email.
| Variable | Default | Description |
|---|---|---|
CAIRN_OPEN_REGISTRATION |
true |
Allow public self-registration. Set to false for any public-facing instance β see Security |
CAIRN_SESSION_LIFETIME_DAYS |
30 |
Browser session lifetime, in days (drives both the DB session and the cookie max-age) |
CAIRN_INVITE_LIFETIME |
72 |
Invitation link lifetime, in hours |
CAIRN_TRUSTED_PROXY |
true |
Trust X-Forwarded-For for the client IP. Keep true only behind a reverse proxy; set false if Cairn is exposed directly |
CAIRN_CLOUDFLARE |
false |
Set to true only if the instance is behind Cloudflare (proxied / orange cloud): the client IP is then read from CF-Connecting-IP. Leave false everywhere else β that header is forgeable when Cloudflare is not in front |
CAIRN_MENU_BANG |
β | Bang that opens the full-screen menu (default !menu, editable in admin if not set here) |
CAIRN_TOTP_ISSUER |
Cairn |
Name shown in your authenticator app |
CAIRN_BOOKMARKLET_TOKEN_LIFETIME |
90 |
Bookmarklet token lifetime in days (a bookmarklet token is a full-access session token β revoke from the account panel if leaked) |
Every CAIRN_* variable supports a _FILE variant: if CAIRN_FOO_FILE is set,
Cairn reads the value from that file instead of the plain env var.
This is the standard pattern for Docker Swarm secrets
and Compose secrets: blocks β plain .env still works unchanged.
services:
cairn:
image: ghcr.io/darktweek/cairn:latest
environment:
CAIRN_BASE_URL: "https://start.example.com"
CAIRN_SESSION_SECRET_FILE: /run/secrets/cairn_session_secret
CAIRN_SMTP_PASS_FILE: /run/secrets/smtp_password
CAIRN_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
secrets:
- cairn_session_secret
- smtp_password
- oidc_client_secret
secrets:
cairn_session_secret:
external: true # docker secret create cairn_session_secret <(openssl rand -base64 32)
smtp_password:
external: true
oidc_client_secret:
external: trueBoth
CAIRN_FOOandCAIRN_FOO_FILEcan coexist in the same deployment β the_FILEvariant takes priority. You can migrate secret-by-secret without touching variables that are already plain env vars.
Serve Cairn over HTTPS behind your proxy: remove the ports mapping from the
cairn service and let the proxy reach it. Keep CAIRN_TRUSTED_PROXY=true (the
default) so client IPs are read from X-Forwarded-For, and set CAIRN_BASE_URL
to your public https://β¦ URL (this also makes session cookies Secure).
services:
cairn:
image: ghcr.io/darktweek/cairn:latest
restart: unless-stopped
environment:
CAIRN_BASE_URL: "https://start.example.com"
CAIRN_SESSION_SECRET: "change-me"
volumes:
- cairn_data:/data
networks:
- traefik_proxy
labels:
traefik.enable: "true"
traefik.http.routers.cairn.rule: "Host(`start.example.com`)"
traefik.http.routers.cairn.entrypoints: "websecure"
traefik.http.routers.cairn.tls.certresolver: "cloudflare"
traefik.http.services.cairn.loadbalancer.server.port: "8080"
volumes:
cairn_data:
networks:
traefik_proxy:
external: trueKeep ports: - "127.0.0.1:8080:8080" on the service, then:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}Accepted formats:
| Type | Extensions |
|---|---|
| Image | .jpg .jpeg .png .webp .avif |
| Video | .mp4 .webm |
Adaptive theme: Cairn samples the luminance of your active wallpaper and automatically switches between light and dark text for readability.
Single pin: Only one wallpaper can be pinned as favorite at a time. Pinning a new one automatically unpins the previous.
Hidden bookmarks
Any bookmark can be marked as hidden via the toggle in the add/edit modal.
- Hidden bookmarks are excluded from all searches by default.
- They remain visible in the bookmark manager panel (management view).
- To search them, use
!h <query>in the search bar β this searches hidden bookmarks only and opens the results in the bookmark panel. - Hidden bookmarks are shown with a π badge and dimmed style in the panel.
Save any page in one click from any browser, including mobile Safari:
- Go to Account β Bookmarklet β Generate bookmarklet
- Drag the link to your browser's bookmarks bar
- Click it on any page to save it to Cairn
Registration is controlled by CAIRN_OPEN_REGISTRATION (and the Admin β Settings β Registration toggle).
- Invite-only (recommended): the admin sends invite links by email; invited signups are TOTP-enforced.
- Open registration: anyone can self-register with username + email + password. These accounts are password-only (no TOTP, no email verification).
β οΈ The first account created becomes the instanceowner(full control). Open registration auto-disables once that first account exists, so create your owner account first. For a public-facing instance, also start withCAIRN_OPEN_REGISTRATION=falseβ see Security.
Cairn ships a Bitwarden-style authorization model with two independent layers.
Collections & sharing. Bookmarks live in collections (each with a nested folder tree). Every user has one personal collection. Any other collection can be shared with:
- individual users, or
- groups (teams),
at one of three permission levels: view (read-only), edit (add/modify/delete bookmarks & folders), or manage (edit + manage the collection's shares). The collection picker marks shared collections with a π₯ indicator. Sharing can optionally email the new collaborator.
Instance roles (RBAC). What a user may do at the system level is governed by roles, each a bundle of fine-grained permissions from a fixed catalog:
audit.view Β· bookmarks.import_export Β· collections.create Β· collections.manage_all Β· collections.delete_any Β· groups.manage Β· users.manage Β· settings.manage Β· roles.manage
Three system roles are seeded β owner (all permissions), admin (day-to-day administration), user (no instance permissions) β and admins with roles.manage can create custom roles from Admin β Roles. A user can hold multiple roles; their effective permissions are the union. Guard-rails: an actor can only grant permissions they hold, can't strip the last owner, and can't modify a user more privileged than themselves.
GDPR / privacy policies. Under Admin β Settings, three opt-in policies (all off by default) let you: allow admins to manage all collections (logged in the audit trail), restrict collection creation, and restrict collection deletion. By default an admin cannot see other users' private collections.
You don't need Go installed β everything builds in Docker.
git clone https://github.com/darktweek/cairn.git
cd cairn
# Build the image locally
docker build -t cairn:dev .
# β¦or run the CI pipeline (build, vet, test)
docker run --rm -v "$PWD":/src -w /src golang:1.26 \
sh -c 'go build ./... && go vet ./... && go test ./...'To run your local build with Compose, add build: . (and image: cairn:dev) to
the cairn service. Set CAIRN_ENV: development for human-readable logs.
See CONTRIBUTING.md for the dev workflow and contributor license terms.
cairn/
βββ cmd/cairn/ β entrypoint (main.go, router, graceful shutdown)
βββ internal/
β βββ config/ β config loading and validation
β βββ db/ β SQLite setup + embedded goose migrations
β βββ model/ β data structs
β βββ repository/ β database access (interfaces + SQLite implementations)
β βββ service/ β business logic (auth, bookmarks, collections, rbac, groups, wallpapers, adminβ¦)
β βββ handler/ β HTTP JSON handlers
β βββ middleware/ β auth, permission/RBAC, rate limit, CORS, headers, bookmarklet
βββ web/static/
β βββ index.html β HTML shell
β βββ style.css β styles (CSS variables, adaptive theme)
β βββ app.js β vanilla JS SPA (zero dependencies)
βββ .env.example β environment variable template
βββ Dockerfile β multi-stage build β ~5 MB scratch image
βββ compose.yaml β deployment (pulls the GHCR image)
βββ .github/workflows/ β CI (build/vet/test) + Release (GHCR image on tags)
| Area | Implementation |
|---|---|
| Passwords | Argon2id (time=1, memory=64 MB, threads=4) |
| Sessions | SHA-256 hashed tokens, HttpOnly + Secure + SameSite=Strict cookies, configurable lifetime |
| TOTP | RFC 6238 for invited/email-verified signups β server-generated QR code, secrets encrypted AES-256-GCM at rest |
| Rate limiting | Two-layer: per-account (10/5 min) + per-IP fallback (30/5 min); register/forgot 3/hour |
| Authorization | Permission-checked admin routes; RBAC anti-escalation (grant only what you hold), last-owner guard, no modifying a more-privileged user; collection ACL enforced server-side (view/edit/manage), forbidden/unknown returns 404 to hide existence |
| User isolation | Data scoped per user / per collection; media served behind auth and path-traversal-safe |
| CORS | Locked to CAIRN_BASE_URL; cross-origin requests get no Access-Control-Allow-Origin |
| Dependencies | govulncheck clean; pure-Go, zero CGO |
| Uploads | Magic bytes validated, server-generated filenames |
| Container | scratch base, read-only FS, no-new-privileges, CAP_DROP ALL |
| HTTP headers | CSP, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy |
| Audit log | All security events logged (login, logout, password change, TOTP, account lifecycle, admin actions) |
| GDPR | Hard delete purges all user data and media; audit entries retain username in metadata, user_id set to NULL |
| Email TLS | Port 465 β implicit TLS (SMTPS); port 587 β STARTTLS upgrade |
Before exposing Cairn to the internet:
- Create your owner account first β open registration auto-disables once it exists. For belt-and-braces, also set
CAIRN_OPEN_REGISTRATION=false. - Serve over HTTPS behind a reverse proxy so session cookies are
Secure; let the proxy add HSTS. - Keep
CAIRN_TRUSTED_PROXY=trueonly behind that proxy β otherwise set it tofalseto preventX-Forwarded-Forspoofing. - Use a strong random
CAIRN_SESSION_SECRET(β₯ 32 chars, e.g.openssl rand -base64 32). - Consider a shorter session:
CAIRN_SESSION_LIFETIME_DAYS=7. - Prefer invitations (TOTP-enforced) over open registration.
A full pre-release review (auth, sessions, RBAC/ACL, injection, XSS, CSRF, SSRF, headers, uploads, dependencies) is summarised in SECURITY.md.
| Component | Choice |
|---|---|
| Language | Go 1.26 |
| Router | chi v5 |
| Database | SQLite (WAL mode) |
| SQLite driver | modernc.org/sqlite (pure Go, zero CGO) |
| Migrations | goose (embedded) |
| QR code | skip2/go-qrcode (server-side PNG for TOTP setup) |
| Docker image | scratch (~5 MB) |
| Frontend | Vanilla JS, zero dependencies |
- π¬ Discord β questions, ideas, support: https://discord.gg/2S9v5MKfGg
- π Issues β bug reports & feature requests on GitHub
- π€ Contributing β see
CONTRIBUTING.md
Cairn is dual-licensed:
- GNU AGPL-v3 for open-source / self-hosted use β see
LICENSE. If you run a modified version as a network service, the AGPL (Β§13) requires you to make your source modifications available to its users. - Commercial license β to embed Cairn in a proprietary product, or to offer
it as a hosted/SaaS service without the AGPL's source-disclosure
obligations. See
LICENSING.md.
Β© 2026 darktweek. Contributions are accepted under CONTRIBUTING.md.


