Skip to content
 
 

Repository files navigation

Spot

Drop a folder, get a spot.

Spot deploy UI

Spot is a self-hosted internal hosting platform for small web things that should be easy to ship and private by default. Put it on a VM inside your NetBird or Tailscale mesh, drop a folder of HTML from the browser or CLI, and get a private URL like http://demo.spot.example.com.

For trusted homelabs, Spot can also run in single-user mode: no provider API, no per-person identity, just one fixed owner for everyone who can reach the box.

How It Works

mesh peer
   |
   | WireGuard mesh decides who can reach the VM
   v
spot VM
  |- spot-api   Go binary: platform UI, deployed sites, APIs, realtime
  |- SQLite     metadata, documents, deploy registry, audit log
  `- S3/RustFS  deployed site files and uploads

Authentication is the mesh itself. NetBird or Tailscale decides who can reach the VM, and Spot maps the request's mesh source IP to an identity through the provider API. There are no cookies, sessions, OIDC redirects, or per-site deploy keys.

S3-compatible storage stays in the architecture because deployed sites and uploads can be large and are the easiest part to keep in blob storage. SQLite is the only metadata database.

Quick Start

cp .env.example .env
just up

The local compose stack runs:

  • spot-api on http://spot.localhost:8080
  • RustFS on loopback for S3-compatible blob storage
  • SQLite in the spot-data volume

Local development uses SPOT_DEV_IDENTITY_EMAIL=dev@spot.local by default so deploys work without a mesh API. Shared deployments must use a real mesh provider or SPOT_AUTH_MODE=single-user.

Deploy a folder with an index.html:

cli/spot deploy demo examples/demo

Then open:

http://demo.spot.localhost:8080/

The CLI targets SPOT_URL and defaults to http://spot.localhost:8080. Persist another deployment with:

mkdir -p ~/.config/spot
printf 'SPOT_URL=https://spot.example.com\n' > ~/.config/spot/env

The apex page is also a deployer: open http://spot.localhost:8080/, drop a folder or index.html, pick a name, and launch.

Spot Show Reports

Spot Show turns a JSON card/block document into a visual report site. Use it for agent work logs, architecture sketches, review notes, diffs, terminal output, structured data, diagrams, screenshots, or small sandboxed demos.

Start from a template:

cli/spot show init show.json

Deploy it to a stable site name:

cli/spot show deploy spot-show show.json

Spot Show deploys capture and upload _screenshot.png by default so the gallery uses a real thumbnail. For custom static sites, use cli/spot deploy --screenshot <name> <folder> when the site should appear in the public gallery with a preview.

For local demos, open the browser after deploy:

cli/spot show deploy --open spot-show show.json

For active iteration, watch the file and redeploy on changes:

cli/spot show watch --open spot-show show.json

Open the generated site at:

http://spot-show.spot.localhost:8080/

The generated page includes /spot-live.js, so open tabs refresh after each redeploy. The generated _spot.json uses the show's title and description for gallery metadata.

The full schema is served by the running Spot instance:

cli/spot show-schema

An example report lives in examples/spot-show/show.json and can be deployed to the local stack with:

just deploy-show-demo

Deployment Modes

Prebuilt Images

Multi-architecture images (linux/amd64 and linux/arm64) are published to the GitHub Container Registry:

  • ghcr.io/melonamin/spot-api — the Spot server.
  • ghcr.io/melonamin/spot-caddy — Caddy with the Cloudflare DNS module, for SPOT_TLS_MODE=tls-cloudflare.

Available tags:

  • latest — the most recent tagged release.
  • X.Y.Z and X.Y — a specific release.
  • edge — the current main branch.
  • sha-<commit> — a specific commit.

The Compose files reference these images by default, so the commands below pull a prebuilt image when you omit --build. Images are published starting with the first tagged release; before a release exists, or to run unreleased code, build from source by adding --build. To pin a release, set SPOT_API_IMAGE (and SPOT_CADDY_IMAGE for the TLS overlay):

SPOT_API_IMAGE=ghcr.io/melonamin/spot-api:0.1.0 \
  docker compose -f docker-compose.yml -f docker-compose.mesh.yml up -d

Mesh Identity

Use this for the normal shared deployment model.

  1. Point the apex and wildcard DNS at the VM's mesh IP:

    spot.example.com      A/AAAA  <vm mesh ip>
    *.spot.example.com    A/AAAA  <vm mesh ip>
    
  2. Set a non-local domain and replace default RustFS credentials:

    SPOT_MESH_DOMAIN=spot.example.com
    RUSTFS_ACCESS_KEY=...
    RUSTFS_SECRET_KEY=...
  3. Configure exactly one provider:

    NETBIRD_API_URL=https://netbird.example.com
    NETBIRD_API_TOKEN=...

    or:

    TAILSCALE_OAUTH_CLIENT_ID=...
    TAILSCALE_OAUTH_CLIENT_SECRET=...
    TAILSCALE_TAILNET=-
  4. Start with host networking for spot-api, so it sees the real mesh peer source IP:

    docker compose -f docker-compose.yml -f docker-compose.mesh.yml up -d --build

To let Spot serve HTTPS directly, add the TLS overlay:

docker compose \
  -f docker-compose.yml \
  -f docker-compose.mesh.yml \
  -f docker-compose.tls.yml \
  up -d --build

SPOT_TLS_MODE=tls-internal uses Caddy's internal CA. SPOT_TLS_MODE=tls-cloudflare uses DNS-01 wildcard certificates and requires CF_API_TOKEN with Zone:Zone:Read and Zone:DNS:Edit.

If you put another TLS proxy in front, preserve the real source IP and add only that proxy to SPOT_TRUSTED_PROXIES so Spot can trust X-Forwarded-Proto and X-Forwarded-For.

Forward-Auth Identity (Pangolin / SSO proxy)

Use this when an authenticating reverse proxy fronts Spot and asserts who the caller is via HTTP headers — for example Pangolin, Authelia, Authentik, or a bespoke proxy in front of agent sandboxes that deploy on a user's behalf. The proxy handles login (SSO/OIDC); Spot reads the asserted identity in place of a mesh peer lookup:

Proxy header Spot identity field Default name
user id peer_name Remote-User
email email (ownership + _access.json) Remote-Email
full name name Remote-Name
groups / role groups (_access.json) Remote-Groups
  1. Enable forward auth, overriding header names if your proxy differs:

    SPOT_FORWARD_AUTH=1
    # Pangolin asserts a single Remote-Role:
    SPOT_FORWARD_AUTH_GROUPS_HEADER=Remote-Role
  2. Trust only the proxy. Spot honors Remote-* solely from the immediate socket peer, so set this to the proxy's IP/CIDR:

    SPOT_TRUSTED_PROXIES=10.0.0.0/24

Forward auth can run alongside a mesh provider (proxy identity wins when its headers are present, otherwise the mesh resolves by IP) or as the only identity source.

Notes:

  • The trusted proxy MUST strip any client-supplied Remote-* before injecting its own. Spot believes whatever a trusted peer sends; the same headers from an untrusted socket are ignored.
  • To run the proxy off-mesh (where its source IP isn't a reliable identifier), set SPOT_FORWARD_AUTH_SECRET to a long random value and have the proxy send it in the X-Spot-Forward-Auth-Secret header. When set, the secret is required and replaces the source-IP check.
  • Pangolin only emits identity headers under SSO. PIN, password, and shareable links authenticate but carry no identity, so restricted sites behind Pangolin require SSO.
  • If a TLS proxy (Caddy) sits between Pangolin and spot-api, that hop must be private to Pangolin. Do not expose a pass-through Caddy listener directly to clients after adding Caddy to SPOT_TRUSTED_PROXIES: Caddy forwards request headers by default, so Spot would trust spoofed Remote-* from anyone who can reach it. Make the auth proxy the public entrypoint, or strip and re-inject identity headers only after auth.
  • Email is the principal: a user keeps the same site ownership whether they arrive via the proxy or the mesh, as long as the email matches.

Single-User Homelab

Use this when LAN/VPN/firewall access is the boundary and everyone who can reach Spot should act as the same owner:

docker compose -f docker-compose.yml -f docker-compose.homelab.yml up -d --build

Set SPOT_HOMELAB_DOMAIN if you do not want the default spot.home.arpa, and publish the apex plus wildcard to the host's LAN or VPN IP.

Keep SPOT_SINGLE_USER_EMAIL stable. It is the owner key used for deploy authorization.

Single Binary, Local Storage

This is the smallest install. It uses SQLite plus filesystem storage, no RustFS/S3:

cd server
go build -o spot-api .

./spot-api serve \
  --storage local \
  --auth single-user \
  --domain spot.home.arpa \
  --data-dir /var/lib/spot \
  --listen :8080

Data lands under:

/var/lib/spot/
  spot.db
  sites/<site>/...
  uploads/<site>/<id>/<name>

Point both DNS records at the machine:

spot.home.arpa      A/AAAA  <vm lan or vpn ip>
*.spot.home.arpa    A/AAAA  <vm lan or vpn ip>

Open http://spot.home.arpa:8080/ unless you put TLS in front.

Single Binary, S3 Storage

This keeps the one-process Spot runtime while storing large files in S3-compatible blob storage:

./spot-api serve \
  --storage s3 \
  --auth single-user \
  --domain spot.home.arpa \
  --data-dir /var/lib/spot \
  --listen :8080

Required environment:

SPOT_S3_ENDPOINT=127.0.0.1:9000
SPOT_S3_ACCESS_KEY=...
SPOT_S3_SECRET_KEY=...
SPOT_UPLOADS_BUCKET=spot-uploads
SPOT_SITES_BUCKET=spot-sites

The server creates the buckets if they are missing.

Configuration

Environment variables and CLI flags overlap for the install-critical settings:

--storage   SPOT_STORAGE_MODE      s3 or local
--auth      SPOT_AUTH_MODE         auto or single-user
--domain    SPOT_DOMAIN
--data-dir  SPOT_DATA_DIR
--sqlite    SPOT_SQLITE_PATH
--listen    PORT

SPOT_STORAGE_MODE defaults to s3. SPOT_SQLITE_PATH defaults to $SPOT_DATA_DIR/spot.db.

Spot derives generated URLs from the request. Direct HTTP returns http://, direct TLS returns https://, and trusted proxies may send X-Forwarded-Proto. There is no configured public scheme.

Production Deploy

Deploy the committed tree with the TLS overlay and orphan cleanup:

scripts/deploy-prod.sh

Production deploys run a gallery backfill after the containers restart. The backfill writes missing _spot.json metadata and captures missing _screenshot.png previews for public sites.

To populate gallery metadata for sites deployed before _spot.json and screenshots existed, run the maintenance backfill from the production environment:

spot-api backfill-gallery
spot-api backfill-gallery -write -screenshots

The first command is a dry run. With -write, Spot updates the site metadata columns and writes missing _spot.json files directly to site storage. With -screenshots, it captures missing _screenshot.png files for public sites using headless Chromium. The command does not use /api/deploy, so it does not change site owners, create deploy audit entries, or touch updated_at.

SDK

Sites load /spot.js from their own origin:

<script src="/spot.js"></script>

All calls are same-origin:

const me = await spot.me(); // { email, name, peer_name, peer_ip, groups, ai_allowed }
const posts = spot.db.collection('posts');
const doc = await posts.create({ title: 'Hello Spot DB' });
const docs = await posts.list();
const next = await posts.list({ limit: 25, after: docs.at(-1)?.id });
const mine = await posts.list({ mine: true });
await posts.updateMine(doc.id, { title: 'Only I can edit this path' });

// Filter, sort, count, batch read, and iterate. where ops: eq ne gt gte lt lte in.
const open = await posts.list({ where: { status: 'open' }, sort: 'priority', order: 'desc' });
const total = await posts.count({ where: { status: 'open' } });
const some = await posts.getMany([doc.id, next?.[0]?.id]);
for await (const p of posts.iterate({ pageSize: 100 })) { /* every doc, paged */ }

// Atomic counter — no read-modify-write, safe under concurrency:
await posts.increment(doc.id, 'views');

Collections are private to their site, except shared-* collections, which live in one global namespace every site can read and write.

Every document records an owner — the mesh identity that created it — so several visitors can keep private records in one shared site. list({ mine: true }) returns only the caller's documents. Writes are not restricted to the owner by default; use updateMine, deleteMine, or { mine: true } on mutations when only the creator should be able to change a document. The owner (a lowercased email or peer IP) is included in every read response, so on a site many visitors can read, treat it as visible to all of them.

Realtime DB subscriptions are process-local and delivered after SQLite commits:

const unsubscribe = posts.subscribe({
  onCreate: (doc) => console.log(doc),
  onUpdate: (doc) => console.log(doc),
  onDelete: (id) => console.log(id),
}, { replay: true }); // replay current docs as onCreate first, then live changes

Every call rejects with a spot.SpotError (status, code, and retryAfter on a 429). The SDK retries rate-limited and transient failures automatically with backoff; tune it with spot.configure({ retry }) or a per-call { retry } option.

Ephemeral realtime rooms are also process-local and are not persisted:

const room = spot.realtime.room('control');
room.on('cursor', ({ from, data }) => drawCursor(from.email, data));
room.onPresence((users) => renderOnline(users));
room.onStatus((status) => renderConnection(status));
room.setPresence({ role: 'operator' });
room.send('cursor', { x: 12, y: 8 });

Access Control

Sites are open to everyone who can reach Spot by default. A site restricts itself by shipping _access.json at its root:

{ "allow": ["alice@example.com", "team-payments"] }

Entries containing @ match email. Other entries match mesh groups. A broken policy fails closed.

The first deploy claims a site name. Later deploys and deletes require the original owner or a platform admin from SPOT_ADMIN_EMAILS or SPOT_ADMIN_GROUPS.

In single-user mode, every visitor has the same configured identity. Ownership still works, but _access.json cannot provide per-person authorization.

Sites can disable source downloads without becoming private:

{ "download": false }

Sites can also describe themselves for /gallery with _spot.json:

{
  "title": "Sketch Pad",
  "description": "Draw quick ideas in the browser.",
  "tags": ["creative", "drawing", "tool"]
}

Tags are optional, normalized to lowercase chips, and searchable/filterable in Gallery. If tags are omitted and the server AI proxy is configured, Spot may suggest tags from the public site's title, description, headings, and filenames. Restricted sites are not auto-tagged.

Platform Pages

  • / is the browser deployer.
  • /spots lists the caller's sites.
  • /gallery lists unrestricted public sites.

Important APIs:

  • POST /api/deploy deploys a site from the apex only.
  • GET /api/sites/mine lists the caller's sites.
  • GET /api/sites/public lists unrestricted sites.
  • DELETE /api/sites/{name} deletes a site, its uploads, private docs, and registry claim.
  • GET /api/download on a site subdomain downloads a source ZIP, unless the site disables downloads.

On the Spot root, the SDK wraps the site APIs:

const mine = await spot.sites.mine();
const publicSites = await spot.sites.public();
await spot.sites.delete('old-demo');

Files, Text AI, Image Generation, and Slack

Uploads go through Spot, so browsers never see storage credentials:

const stored = await spot.files.upload(file);
const files = await spot.files.list();
await spot.files.delete(stored);

Images, PDFs, plain text, audio, and video render inline. HTML, SVG, and unknown types download as attachments with nosniff.

The AI proxy holds the OpenAI-compatible gateway key server-side. For LiteLLM, use the LiteLLM virtual key and proxy URL:

OPENAI_API_KEY=...
OPENAI_BASE_URL=http://litellm:4000
SPOT_AI_MODEL=...
SPOT_AI_IMAGE_MODEL=gemini-3.1-flash-image-preview
const chat = await spot.ai.chat([{ role: 'user', content: 'Summarize my tasks' }]);

// Stream tokens as they arrive:
await spot.ai.stream([{ role: 'user', content: 'Write a haiku' }], {
  onToken: (delta, text) => render(text),
});

const art = await spot.ai.image('A tiny cyberpunk greenhouse at night');
const img = new Image();
img.src = art.images[0].data_url;
document.body.append(img);

Text generation goes through /v1/chat/completions; image generation goes through /v1/images/generations. Image responses include browser-ready images[0].data_url plus b64, mime_type, and model. Set SPOT_AI_IMAGE_MODEL to choose the deployment default, or pass a model such as { model: 'gpt-image-2' } or the LiteLLM Nano Banana 2 alias exposed by your gateway.

By default only the site owner and platform admins may call it. Set SPOT_AI_ACCESS=visitors globally, or opt in a restricted site:

{ "allow": ["team-payments"], "ai": "visitors" }

The Slack proxy holds a single workspace bot token server-side and lets sites post notifications without exposing that token:

SLACK_BOT_TOKEN=xoxb-...
SPOT_SLACK_ACCESS=owners
await spot.slack.send({
  channel: '#signups',
  text: '*New signup* from the guestbook',
});

Set SLACK_BASE_URL only when routing to a compatible test or proxy upstream. By default only the site owner and platform admins may send Slack messages. Set SPOT_SLACK_ACCESS=visitors globally, or opt in a restricted site:

{ "allow": ["team-payments"], "slack": "visitors" }

spot.slack.send passes Slack mrkdwn and blocks through unchanged. External public image URLs in blocks work; Spot file URLs are private to the mesh and cannot be fetched by Slack's servers.

Tests

just test
just test-integration
just e2e

just test runs Go vet/unit tests plus the CLI smoke test. just e2e starts compose, deploys the demo site, exercises static serving, DB APIs, uploads, site deletion, and platform pages.

Production Notes

  • Mesh identity depends on the real peer IP. Run Spot directly on the host network or behind a proxy that preserves source IP correctly.
  • Shared deployments must configure exactly one mesh provider unless SPOT_AUTH_MODE=single-user is set.
  • SPOT_DEV_IDENTITY_EMAIL is accepted only for .localhost.
  • Shared mesh deployments must replace default RustFS credentials.
  • RustFS is convenient for local and small deployments. Any S3-compatible store can replace it.
  • Multi-process Spot against one SQLite file is intentionally not the target. If that becomes necessary, add a SQLite pub/sub layer and explicit leader/runtime coordination then.

About

Self-hosted private hosting for small HTML apps on NetBird or Tailscale

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages