{"id":50964835,"url":"https://github.com/melonamin/spot","last_synced_at":"2026-06-18T18:32:48.510Z","repository":{"id":364646845,"uuid":"1267909998","full_name":"melonamin/spot","owner":"melonamin","description":"Self-hosted private hosting for small HTML apps on NetBird or Tailscale","archived":false,"fork":false,"pushed_at":"2026-06-13T21:03:10.000Z","size":273,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-13T23:08:28.457Z","etag":null,"topics":["caddy","docker-compose","golang","homelab","internal-tools","netbird","self-hosted","static-site-hosting","tailscale","wireguard"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/melonamin.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-13T00:59:21.000Z","updated_at":"2026-06-13T21:03:14.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/melonamin/spot","commit_stats":null,"previous_names":["melonamin/spot"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/melonamin/spot","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/melonamin%2Fspot","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/melonamin%2Fspot/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/melonamin%2Fspot/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/melonamin%2Fspot/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/melonamin","download_url":"https://codeload.github.com/melonamin/spot/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/melonamin%2Fspot/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34503508,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-18T02:00:06.871Z","response_time":128,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["caddy","docker-compose","golang","homelab","internal-tools","netbird","self-hosted","static-site-hosting","tailscale","wireguard"],"created_at":"2026-06-18T18:32:47.514Z","updated_at":"2026-06-18T18:32:48.495Z","avatar_url":"https://github.com/melonamin.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Spot\n\nDrop a folder, get a spot.\n\n![Spot deploy UI](docs/assets/spot-home.png)\n\nSpot is a self-hosted internal hosting platform for small web things that\nshould be easy to ship and private by default. Put it on a VM inside your\nNetBird or Tailscale mesh, drop a folder of HTML from the browser or CLI,\nand get a private URL like `http://demo.spot.example.com`.\n\nFor trusted homelabs, Spot can also run in `single-user` mode: no provider\nAPI, no per-person identity, just one fixed owner for everyone who can\nreach the box.\n\n## How It Works\n\n```text\nmesh peer\n   |\n   | WireGuard mesh decides who can reach the VM\n   v\nspot VM\n  |- spot-api   Go binary: platform UI, deployed sites, APIs, realtime\n  |- SQLite     metadata, documents, deploy registry, audit log\n  `- S3/RustFS  deployed site files and uploads\n```\n\nAuthentication is the mesh itself. NetBird or Tailscale decides who can\nreach the VM, and Spot maps the request's mesh source IP to an identity\nthrough the provider API. There are no cookies, sessions, OIDC redirects,\nor per-site deploy keys.\n\nS3-compatible storage stays in the architecture because deployed sites\nand uploads can be large and are the easiest part to keep in blob storage.\nSQLite is the only metadata database.\n\n## Quick Start\n\n```sh\ncp .env.example .env\njust up\n```\n\nThe local compose stack runs:\n\n- `spot-api` on `http://spot.localhost:8080`\n- RustFS on loopback for S3-compatible blob storage\n- SQLite in the `spot-data` volume\n\nLocal development uses `SPOT_DEV_IDENTITY_EMAIL=dev@spot.local` by\ndefault so deploys work without a mesh API. Shared deployments must use a\nreal mesh provider or `SPOT_AUTH_MODE=single-user`.\n\nDeploy a folder with an `index.html`:\n\n```sh\ncli/spot deploy demo examples/demo\n```\n\nThen open:\n\n```text\nhttp://demo.spot.localhost:8080/\n```\n\nThe CLI targets `SPOT_URL` and defaults to\n`http://spot.localhost:8080`. Persist another deployment with:\n\n```sh\nmkdir -p ~/.config/spot\nprintf 'SPOT_URL=https://spot.example.com\\n' \u003e ~/.config/spot/env\n```\n\nThe apex page is also a deployer: open `http://spot.localhost:8080/`,\ndrop a folder or `index.html`, pick a name, and launch.\n\n## Deployment Modes\n\n### Prebuilt Images\n\nMulti-architecture images (`linux/amd64` and `linux/arm64`) are published to\nthe GitHub Container Registry:\n\n- `ghcr.io/melonamin/spot-api` — the Spot server.\n- `ghcr.io/melonamin/spot-caddy` — Caddy with the Cloudflare DNS module, for\n  `SPOT_TLS_MODE=tls-cloudflare`.\n\nAvailable tags:\n\n- `latest` — the most recent tagged release.\n- `X.Y.Z` and `X.Y` — a specific release.\n- `edge` — the current `main` branch.\n- `sha-\u003ccommit\u003e` — a specific commit.\n\nThe Compose files reference these images by default, so the commands below\npull a prebuilt image when you omit `--build`. Images are published starting\nwith the first tagged release; before a release exists, or to run unreleased\ncode, build from source by adding `--build`. To pin a release, set\n`SPOT_API_IMAGE` (and `SPOT_CADDY_IMAGE` for the TLS overlay):\n\n```sh\nSPOT_API_IMAGE=ghcr.io/melonamin/spot-api:0.1.0 \\\n  docker compose -f docker-compose.yml -f docker-compose.mesh.yml up -d\n```\n\n### Mesh Identity\n\nUse this for the normal shared deployment model.\n\n1. Point the apex and wildcard DNS at the VM's mesh IP:\n\n   ```text\n   spot.example.com      A/AAAA  \u003cvm mesh ip\u003e\n   *.spot.example.com    A/AAAA  \u003cvm mesh ip\u003e\n   ```\n\n2. Set a non-local domain and replace default RustFS credentials:\n\n   ```env\n   SPOT_MESH_DOMAIN=spot.example.com\n   RUSTFS_ACCESS_KEY=...\n   RUSTFS_SECRET_KEY=...\n   ```\n\n3. Configure exactly one provider:\n\n   ```env\n   NETBIRD_API_URL=https://netbird.example.com\n   NETBIRD_API_TOKEN=...\n   ```\n\n   or:\n\n   ```env\n   TAILSCALE_OAUTH_CLIENT_ID=...\n   TAILSCALE_OAUTH_CLIENT_SECRET=...\n   TAILSCALE_TAILNET=-\n   ```\n\n4. Start with host networking for `spot-api`, so it sees the real mesh\n   peer source IP:\n\n   ```sh\n   docker compose -f docker-compose.yml -f docker-compose.mesh.yml up -d --build\n   ```\n\nTo let Spot serve HTTPS directly, add the TLS overlay:\n\n```sh\ndocker compose \\\n  -f docker-compose.yml \\\n  -f docker-compose.mesh.yml \\\n  -f docker-compose.tls.yml \\\n  up -d --build\n```\n\n`SPOT_TLS_MODE=tls-internal` uses Caddy's internal CA.\n`SPOT_TLS_MODE=tls-cloudflare` uses DNS-01 wildcard certificates and\nrequires `CF_API_TOKEN` with Zone:Zone:Read and Zone:DNS:Edit.\n\nIf you put another TLS proxy in front, preserve the real source IP and\nadd only that proxy to `SPOT_TRUSTED_PROXIES` so Spot can trust\n`X-Forwarded-Proto` and `X-Forwarded-For`.\n\n### Forward-Auth Identity (Pangolin / SSO proxy)\n\nUse this when an authenticating reverse proxy fronts Spot and asserts who\nthe caller is via HTTP headers — for example\n[Pangolin](https://pangolin.net), Authelia, Authentik, or a bespoke proxy\nin front of agent sandboxes that deploy on a user's behalf. The proxy\nhandles login (SSO/OIDC); Spot reads the asserted identity in place of a\nmesh peer lookup:\n\n| Proxy header | Spot identity field | Default name |\n| --- | --- | --- |\n| user id | `peer_name` | `Remote-User` |\n| email | `email` (ownership + `_access.json`) | `Remote-Email` |\n| full name | `name` | `Remote-Name` |\n| groups / role | `groups` (`_access.json`) | `Remote-Groups` |\n\n1. Enable forward auth, overriding header names if your proxy differs:\n\n   ```env\n   SPOT_FORWARD_AUTH=1\n   # Pangolin asserts a single Remote-Role:\n   SPOT_FORWARD_AUTH_GROUPS_HEADER=Remote-Role\n   ```\n\n2. Trust only the proxy. Spot honors `Remote-*` solely from the immediate\n   socket peer, so set this to the proxy's IP/CIDR:\n\n   ```env\n   SPOT_TRUSTED_PROXIES=10.0.0.0/24\n   ```\n\nForward auth can run alongside a mesh provider (proxy identity wins when\nits headers are present, otherwise the mesh resolves by IP) or as the only\nidentity source.\n\nNotes:\n\n- The trusted proxy MUST strip any client-supplied `Remote-*` before\n  injecting its own. Spot believes whatever a trusted peer sends; the same\n  headers from an untrusted socket are ignored.\n- To run the proxy off-mesh (where its source IP isn't a reliable identifier),\n  set `SPOT_FORWARD_AUTH_SECRET` to a long random value and have the proxy send\n  it in the `X-Spot-Forward-Auth-Secret` header. When set, the secret is\n  required and replaces the source-IP check.\n- Pangolin only emits identity headers under SSO. PIN, password, and\n  shareable links authenticate but carry no identity, so restricted sites\n  behind Pangolin require SSO.\n- If a TLS proxy (Caddy) sits between Pangolin and `spot-api`, that hop\n  must be private to Pangolin. Do not expose a pass-through Caddy listener\n  directly to clients after adding Caddy to `SPOT_TRUSTED_PROXIES`: Caddy\n  forwards request headers by default, so Spot would trust spoofed\n  `Remote-*` from anyone who can reach it. Make the auth proxy the public\n  entrypoint, or strip and re-inject identity headers only after auth.\n- Email is the principal: a user keeps the same site ownership whether they\n  arrive via the proxy or the mesh, as long as the email matches.\n\n### Single-User Homelab\n\nUse this when LAN/VPN/firewall access is the boundary and everyone who\ncan reach Spot should act as the same owner:\n\n```sh\ndocker compose -f docker-compose.yml -f docker-compose.homelab.yml up -d --build\n```\n\nSet `SPOT_HOMELAB_DOMAIN` if you do not want the default\n`spot.home.arpa`, and publish the apex plus wildcard to the host's LAN or\nVPN IP.\n\nKeep `SPOT_SINGLE_USER_EMAIL` stable. It is the owner key used for deploy\nauthorization.\n\n### Single Binary, Local Storage\n\nThis is the smallest install. It uses SQLite plus filesystem storage, no\nRustFS/S3:\n\n```sh\ncd server\ngo build -o spot-api .\n\n./spot-api serve \\\n  --storage local \\\n  --auth single-user \\\n  --domain spot.home.arpa \\\n  --data-dir /var/lib/spot \\\n  --listen :8080\n```\n\nData lands under:\n\n```text\n/var/lib/spot/\n  spot.db\n  sites/\u003csite\u003e/...\n  uploads/\u003csite\u003e/\u003cid\u003e/\u003cname\u003e\n```\n\nPoint both DNS records at the machine:\n\n```text\nspot.home.arpa      A/AAAA  \u003cvm lan or vpn ip\u003e\n*.spot.home.arpa    A/AAAA  \u003cvm lan or vpn ip\u003e\n```\n\nOpen `http://spot.home.arpa:8080/` unless you put TLS in front.\n\n### Single Binary, S3 Storage\n\nThis keeps the one-process Spot runtime while storing large files in\nS3-compatible blob storage:\n\n```sh\n./spot-api serve \\\n  --storage s3 \\\n  --auth single-user \\\n  --domain spot.home.arpa \\\n  --data-dir /var/lib/spot \\\n  --listen :8080\n```\n\nRequired environment:\n\n```env\nSPOT_S3_ENDPOINT=127.0.0.1:9000\nSPOT_S3_ACCESS_KEY=...\nSPOT_S3_SECRET_KEY=...\nSPOT_UPLOADS_BUCKET=spot-uploads\nSPOT_SITES_BUCKET=spot-sites\n```\n\nThe server creates the buckets if they are missing.\n\n## Configuration\n\nEnvironment variables and CLI flags overlap for the install-critical\nsettings:\n\n```text\n--storage   SPOT_STORAGE_MODE      s3 or local\n--auth      SPOT_AUTH_MODE         auto or single-user\n--domain    SPOT_DOMAIN\n--data-dir  SPOT_DATA_DIR\n--sqlite    SPOT_SQLITE_PATH\n--listen    PORT\n```\n\n`SPOT_STORAGE_MODE` defaults to `s3`. `SPOT_SQLITE_PATH` defaults to\n`$SPOT_DATA_DIR/spot.db`.\n\nSpot derives generated URLs from the request. Direct HTTP returns\n`http://`, direct TLS returns `https://`, and trusted proxies may send\n`X-Forwarded-Proto`. There is no configured public scheme.\n\n## Production Deploy\n\nDeploy the committed tree with the TLS overlay and orphan cleanup:\n\n```sh\nscripts/deploy-prod.sh\n```\n\n## SDK\n\nSites load `/spot.js` from their own origin:\n\n```html\n\u003cscript src=\"/spot.js\"\u003e\u003c/script\u003e\n```\n\nAll calls are same-origin:\n\n```js\nconst me = await spot.me(); // { email, name, peer_name, peer_ip, groups, ai_allowed }\nconst posts = spot.db.collection('posts');\nconst doc = await posts.create({ title: 'Hello Spot DB' });\nconst docs = await posts.list();\nconst next = await posts.list({ limit: 25, after: docs.at(-1)?.id });\nconst mine = await posts.list({ mine: true });\nawait posts.updateMine(doc.id, { title: 'Only I can edit this path' });\n\n// Filter, sort, count, batch read, and iterate. where ops: eq ne gt gte lt lte in.\nconst open = await posts.list({ where: { status: 'open' }, sort: 'priority', order: 'desc' });\nconst total = await posts.count({ where: { status: 'open' } });\nconst some = await posts.getMany([doc.id, next?.[0]?.id]);\nfor await (const p of posts.iterate({ pageSize: 100 })) { /* every doc, paged */ }\n\n// Atomic counter — no read-modify-write, safe under concurrency:\nawait posts.increment(doc.id, 'views');\n```\n\nCollections are private to their site, except `shared-*` collections,\nwhich live in one global namespace every site can read and write.\n\nEvery document records an `owner` — the mesh identity that created it — so\nseveral visitors can keep private records in one shared site.\n`list({ mine: true })` returns only the caller's documents. Writes are not\nrestricted to the owner by default; use `updateMine`, `deleteMine`, or\n`{ mine: true }` on mutations when only the creator should be able to change\na document. The `owner` (a lowercased email or peer IP) is included in every\nread response, so on a site many visitors can read, treat it as visible to all\nof them.\n\nRealtime DB subscriptions are process-local and delivered after SQLite\ncommits:\n\n```js\nconst unsubscribe = posts.subscribe({\n  onCreate: (doc) =\u003e console.log(doc),\n  onUpdate: (doc) =\u003e console.log(doc),\n  onDelete: (id) =\u003e console.log(id),\n}, { replay: true }); // replay current docs as onCreate first, then live changes\n```\n\nEvery call rejects with a `spot.SpotError` (`status`, `code`, and `retryAfter`\non a 429). The SDK retries rate-limited and transient failures automatically\nwith backoff; tune it with `spot.configure({ retry })` or a per-call\n`{ retry }` option.\n\nEphemeral realtime rooms are also process-local and are not persisted:\n\n```js\nconst room = spot.realtime.room('control');\nroom.on('cursor', ({ from, data }) =\u003e drawCursor(from.email, data));\nroom.onPresence((users) =\u003e renderOnline(users));\nroom.onStatus((status) =\u003e renderConnection(status));\nroom.setPresence({ role: 'operator' });\nroom.send('cursor', { x: 12, y: 8 });\n```\n\n## Access Control\n\nSites are open to everyone who can reach Spot by default. A site restricts\nitself by shipping `_access.json` at its root:\n\n```json\n{ \"allow\": [\"alice@example.com\", \"team-payments\"] }\n```\n\nEntries containing `@` match email. Other entries match mesh groups. A\nbroken policy fails closed.\n\nThe first deploy claims a site name. Later deploys and deletes require\nthe original owner or a platform admin from `SPOT_ADMIN_EMAILS` or\n`SPOT_ADMIN_GROUPS`.\n\nIn `single-user` mode, every visitor has the same configured identity.\nOwnership still works, but `_access.json` cannot provide per-person\nauthorization.\n\nSites can disable source downloads without becoming private:\n\n```json\n{ \"download\": false }\n```\n\n## Platform Pages\n\n- `/` is the browser deployer.\n- `/spots` lists the caller's sites.\n- `/gallery` lists unrestricted public sites.\n\nImportant APIs:\n\n- `POST /api/deploy` deploys a site from the apex only.\n- `GET /api/sites/mine` lists the caller's sites.\n- `GET /api/sites/public` lists unrestricted sites.\n- `DELETE /api/sites/{name}` deletes a site, its uploads, private docs,\n  and registry claim.\n- `GET /api/download` on a site subdomain downloads a source ZIP,\n  unless the site disables downloads.\n\nOn the Spot root, the SDK wraps the site APIs:\n\n```js\nconst mine = await spot.sites.mine();\nconst publicSites = await spot.sites.public();\nawait spot.sites.delete('old-demo');\n```\n\n## Files, Text AI, Image Generation, and Slack\n\nUploads go through Spot, so browsers never see storage credentials:\n\n```js\nconst stored = await spot.files.upload(file);\nconst files = await spot.files.list();\nawait spot.files.delete(stored);\n```\n\nImages, PDFs, plain text, audio, and video render inline. HTML, SVG, and\nunknown types download as attachments with `nosniff`.\n\nThe AI proxy holds the OpenAI-compatible gateway key server-side. For LiteLLM,\nuse the LiteLLM virtual key and proxy URL:\n\n```env\nOPENAI_API_KEY=...\nOPENAI_BASE_URL=http://litellm:4000\nSPOT_AI_MODEL=...\nSPOT_AI_IMAGE_MODEL=gemini-3.1-flash-image-preview\n```\n\n```js\nconst chat = await spot.ai.chat([{ role: 'user', content: 'Summarize my tasks' }]);\n\n// Stream tokens as they arrive:\nawait spot.ai.stream([{ role: 'user', content: 'Write a haiku' }], {\n  onToken: (delta, text) =\u003e render(text),\n});\n\nconst art = await spot.ai.image('A tiny cyberpunk greenhouse at night');\nconst img = new Image();\nimg.src = art.images[0].data_url;\ndocument.body.append(img);\n```\n\nText generation goes through `/v1/chat/completions`; image generation goes\nthrough `/v1/images/generations`. Image responses include browser-ready\n`images[0].data_url` plus `b64`, `mime_type`, and `model`. Set\n`SPOT_AI_IMAGE_MODEL` to choose the deployment default, or pass a model such\nas `{ model: 'gpt-image-2' }` or the LiteLLM Nano Banana 2 alias exposed by\nyour gateway.\n\nBy default only the site owner and platform admins may call it. Set\n`SPOT_AI_ACCESS=visitors` globally, or opt in a restricted site:\n\n```json\n{ \"allow\": [\"team-payments\"], \"ai\": \"visitors\" }\n```\n\nThe Slack proxy holds a single workspace bot token server-side and lets sites\npost notifications without exposing that token:\n\n```env\nSLACK_BOT_TOKEN=xoxb-...\nSPOT_SLACK_ACCESS=owners\n```\n\n```js\nawait spot.slack.send({\n  channel: '#signups',\n  text: '*New signup* from the guestbook',\n});\n```\n\nSet `SLACK_BASE_URL` only when routing to a compatible test or proxy upstream.\nBy default only the site owner and platform admins may send Slack messages.\nSet `SPOT_SLACK_ACCESS=visitors` globally, or opt in a restricted site:\n\n```json\n{ \"allow\": [\"team-payments\"], \"slack\": \"visitors\" }\n```\n\n`spot.slack.send` passes Slack mrkdwn and `blocks` through unchanged. External\npublic image URLs in blocks work; Spot file URLs are private to the mesh and\ncannot be fetched by Slack's servers.\n\n## Tests\n\n```sh\njust test\njust test-integration\njust e2e\n```\n\n`just e2e` starts compose, deploys the demo site, exercises static\nserving, DB APIs, uploads, site deletion, and platform pages.\n\n## Production Notes\n\n- Mesh identity depends on the real peer IP. Run Spot directly on the\n  host network or behind a proxy that preserves source IP correctly.\n- Shared deployments must configure exactly one mesh provider unless\n  `SPOT_AUTH_MODE=single-user` is set.\n- `SPOT_DEV_IDENTITY_EMAIL` is accepted only for `.localhost`.\n- Shared mesh deployments must replace default RustFS credentials.\n- RustFS is convenient for local and small deployments. Any\n  S3-compatible store can replace it.\n- Multi-process Spot against one SQLite file is intentionally not the\n  target. If that becomes necessary, add a SQLite pub/sub layer and\n  explicit leader/runtime coordination then.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmelonamin%2Fspot","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmelonamin%2Fspot","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmelonamin%2Fspot/lists"}