One small Go server that hosts many static sites with subdomain routing, an
rsync-style deploy flow, and a batteries-included client-side JS API — a document
DB with realtime subscriptions, an AI chat proxy, file uploads, websocket
channels, and identity. Drop a <script src="/shared.js"> into any page and go.
No auth. Single user, trusted network only. Anyone who can reach the port can read and write everything. Run it on your LAN or behind a VPN, not the open internet.
This project is a self-hosted reimplementation of the ideas behind Quick, Shopify's internal hosting platform that "lets anyone at Shopify ship a site in seconds." The architecture (deliberately simple single-server hosting, FTP-style deploys, a fixed client API with db/ai/uploads/websockets/identity, no permissions by design) comes from their engineering write-up; this codebase is an independent from-scratch implementation for personal use, and is not affiliated with Shopify.
direnv allow # or: nix develop
# run the server
go run ./cmd/sharedd
# in another shell: build the CLI and deploy the example site
go build -o bin/ ./...
bin/shared deploy examples/hello --name hello
# open it
xdg-open http://hello.localhost:8787The homepage at http://localhost:8787 lists all deployed sites, each with its
on-disk size and last-updated time. Each site card also has a × button that
deletes the site and all its data (same as shared rm).
Two parts: sharedd, the server you host on one Linux box on your network,
and shared, the CLI you run from any machine to deploy to it. The server
only ships for Linux; the CLI ships for Linux and Windows.
NixOS / Nix. The flake packages both binaries:
nix profile install github:sdelcore/sharedOn NixOS, use the bundled module instead — it runs sharedd as a hardened
systemd service with state in /var/lib/shared:
{
inputs.shared.url = "github:sdelcore/shared";
}{ inputs, ... }:
{
imports = [ inputs.shared.nixosModules.default ];
services.shared = {
enable = true;
baseHost = "shared.tap"; # sites at <name>.shared.tap
openFirewall = true;
# environmentFile = "/run/secrets/shared.env"; # OPENAI_BASE_URL, OPENAI_API_KEY
};
}Ubuntu / other Linux. Download a static binary tarball from the releases page and install it:
tar -xzf shared_*_linux_amd64.tar.gz
sudo install -m 0755 sharedd shared /usr/local/bin/Or build from source. This needs Go 1.24+, which is newer than what Ubuntu 24.04's apt ships — install it from go.dev/dl:
CGO_ENABLED=0 go install github.com/sdelcore/shared/cmd/...@latestThe binaries are pure Go and fully static; nothing else is required. To run
the server under systemd, drop this in /etc/systemd/system/shared.service:
[Unit]
Description=shared site platform
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/sharedd
Environment=SHARED_ADDR=:8787
Environment=SHARED_DATA=/var/lib/shared
Environment=SHARED_BASE_HOST=localhost
DynamicUser=yes
StateDirectory=shared
WorkingDirectory=/var/lib/shared
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now sharedAny machine on the network can deploy to the server; point the CLI at it with
--server or $SHARED_SERVER (default http://localhost:8787).
Linux / macOS. The linux release tarball above includes the CLI, or:
go install github.com/sdelcore/shared/cmd/shared@latestWindows (CLI only — the server is not supported on Windows). Download
shared_<version>_windows_amd64.zip (or arm64) from the
releases page, unzip
shared.exe somewhere on PATH, and set the server once:
setx SHARED_SERVER http://shared.tap
shared listOr let your agent do it. The repo ships an install-shared-cli agent skill that picks the right method for the current OS. Copy it into your agent's skill directory and ask it to install the CLI:
# Claude Code
git clone --depth 1 https://github.com/sdelcore/shared /tmp/shared-skill
cp -r /tmp/shared-skill/skills/install-shared-cli ~/.claude/skills/The shared binary talks to the server over HTTP (--server, default
http://localhost:8787 or $SHARED_SERVER).
shared init [dir] # scaffold index.html + a shared-sites agent skill
shared deploy [dir] --name NAME # pack a directory and deploy it (--force skips
# the overwrite check)
shared list # deployed sites with size, views, last deployer
shared open NAME # print and open a site URL
shared versions NAME # a site's saved versions, newest first
shared rollback NAME # roll back to the newest saved version
shared rm NAME # delete a site and all its data
shared backup [file] # download a gzipped tarball of all server datashared init writes a minimal index.html and
.claude/skills/shared-sites/SKILL.md (an agent skill documenting the client
API), refusing to overwrite existing files. shared backup defaults to
shared-backup-<yyyymmdd-hhmmss>.tar.gz in the current directory.
Each site lives at http://<name>.<base-host><port>/. Modern browsers resolve
any *.localhost name to 127.0.0.1 without /etc/hosts entries, so
http://hello.localhost:8787 just works out of the box. To serve under a real
domain on your LAN, set SHARED_BASE_HOST (e.g. shared.lan) and point a
wildcard DNS record at the box.
Per-site data endpoints (db, uploads, ws) scope strictly to the first label of
the request's Host header — one site cannot reach another site's data, even
from a visitor's browser. The deploy endpoint takes its target from ?site=.
Site names must match ^[a-z0-9][a-z0-9-]{0,62}$.
Every site can load the shared client library:
<script src="/shared.js"></script>The library scopes everything to the current site automatically.
A JSON document store. Documents get server-managed id, createdAt, and
updatedAt fields.
const posts = shared.db.collection('posts');
const doc = await posts.create({ title: 'hi', body: '...' });
const all = await posts.list(); // sorted by createdAt
const one = await posts.get(doc.id);
await posts.update(doc.id, { title: 'hello' });
await posts.delete(doc.id);
// realtime: fires on created / updated / deleted
const sub = posts.subscribe({
onCreate: doc => console.log('created', doc),
onUpdate: doc => console.log('updated', doc),
onDelete: doc => console.log('deleted', doc),
});
// sub.close() to stop. On reconnect after a drop, subscribe re-syncs and
// replays any create/update/delete missed while disconnected.A proxy to an OpenAI-compatible chat API (OpenAI, or a gateway like LiteLLM) — your key stays on the server.
const reply = await shared.ai.chat('Summarize this in one line: ...');
// or full control:
const res = await shared.ai.chat('hi', {
system: 'Be terse.',
max_tokens: 256,
});
// streaming: pass stream + onToken to receive content deltas as they arrive.
// Still resolves with the full assembled string.
const full = await shared.ai.chat('Write a haiku about the sea.', {
stream: true,
onToken: text => process.stdout.write(text),
});Image generation returns a persistent upload URL for the current site:
const { url } = await shared.ai.image('a watercolor fox, minimal');
img.src = url; // served from /uploads/<site>/<rand>-ai.png
// or with options:
const res = await shared.ai.image({ prompt: 'a logo', size: '512x512' });Requires OPENAI_BASE_URL and OPENAI_API_KEY to be set on the server;
image generation also needs SHARED_AI_IMAGE_MODEL (or a model in the call).
Otherwise calls fail with an error message.
const { url } = await shared.uploads.upload(fileInput.files[0]);
img.src = url; // served from /uploads/<site>/<rand>-<name>Per-site broadcast channels. Each message is relayed to every other member of the same channel.
const room = shared.ws.channel('lobby');
room.onMessage(msg => console.log(msg));
room.send('hello everyone');const me = await shared.identity(); // { email, name }Returns the contents of data/identity.json if present, otherwise a default
derived from SHARED_USER (or $USER).
| Method | Path | Description |
|---|---|---|
POST |
/api/deploy?site=N |
Body: gzipped tarball of site dir → {"site","url","version"}; 409 on version conflict (see below) |
POST |
/api/rollback?site=N |
Restore the newest saved version → {"site","url","version"} |
GET |
/api/versions?site=N |
{"current":{"seq","time","deployer","source"},"versions":[{"timestamp"}]}, newest first |
GET |
/api/sites |
{"sites":[{"name","updatedAt","bytes","views","deployer","deploys"}]} (bytes = total on-disk size) |
DELETE |
/api/sites/{name} |
Remove a site and all its data → {"deleted":true} |
GET |
/api/export |
Streams a gzipped tarball of the entire data directory |
GET |
/api/db/{col} |
{"docs":[...]} |
POST |
/api/db/{col} |
JSON body → created doc (201) |
GET |
/api/db/{col}/{id} |
Doc, or 404 {"error":...} |
PUT |
/api/db/{col}/{id} |
JSON body → updated doc |
DELETE |
/api/db/{col}/{id} |
{"deleted":true} |
GET |
/api/db/{col}/subscribe |
WebSocket; pushes {"type","doc"} events |
POST |
/api/ai/chat |
{"messages",...} → {"content","model","stop_reason"}; with "stream":true streams OpenAI SSE |
POST |
/api/ai/image |
{"prompt","model?","size?"} → {"url"} (201, saved to site uploads) |
POST |
/api/uploads |
multipart field file → {"url"} (201) |
GET |
/api/identity |
{"email","name"} |
GET |
/api/ws?channel=C |
WebSocket broadcast channel |
Per-site endpoints (db, uploads, ws) take the site from the request's subdomain.
| Variable | Default | Purpose |
|---|---|---|
SHARED_ADDR |
:8787 |
Listen address |
SHARED_DATA |
./data |
Data directory |
SHARED_BASE_HOST |
localhost |
Base host for subdomain routing |
SHARED_KEEP_VERSIONS |
3 |
Prior deploys to keep per site for rollback; 0 disables versioning |
OPENAI_BASE_URL |
— | OpenAI-compatible base URL (e.g. http://llm.tools.tap/v1); enables /api/ai/chat |
OPENAI_API_KEY |
— | API key/token for the above (e.g. LiteLLM master key) |
SHARED_AI_MODEL |
claude-opus-4-8 |
Default AI model (e.g. zen/kimi-k2.6) |
SHARED_AI_IMAGE_MODEL |
— | Model for /api/ai/image (e.g. gpt-image-1); required unless passed per request |
SHARED_AI_RATE |
30 |
Per-site AI requests/min (burst 10); 0 disables the limiter |
SHARED_USER |
$USER |
Name/email for the default identity |
data/
sites/<name>/ deployed static files, one dir per site
versions/<site>/<ts>/ prior deploys kept for rollback, named by unix time
db/<site>/<col>.json document store, one JSON file per collection
uploads/<site>/ uploaded files
meta/<site>.json deploy history + view counts
identity.json optional identity override: {"email","name"}
Everything is plain files — back it up with whatever you already use.
Each replacement deploy moves the previous site into
versions/<site>/<unix-timestamp>/ and prunes to the newest
SHARED_KEEP_VERSIONS (default 3; set 0 to just discard the old copy).
shared versions mysite # list saved versions, newest first
shared rollback mysite # swap in the newest version; the current one is
# kept as a new version, so rollback is reversible
shared rm mysite # delete the site, its db, uploads, and versionsshared rm (or DELETE /api/sites/<name>) removes everything for a site and
evicts its in-memory collection state, closing any open subscriptions.
Every deploy and rollback is recorded in meta/<site>.json with a sequence
number, timestamp, and deployer identity, sent by the CLI as
X-Shared-Deployer: the git email configured for the deployed directory plus
the machine, e.g. you@example.com (user@hostname), or just user@hostname
without git. shared list and shared versions show who deployed last.
Deploys are also guarded against accidental overwrites, git
--force-with-lease style: the CLI remembers the version it last deployed
(in .shared/state.json next to the site, never uploaded) and sends it as
X-Shared-Prev-Version. If someone else deployed in between, the server
answers 409 with their identity and the CLI asks before overwriting:
hello was deployed by alice@dayman at 2026-07-14T23:40:23Z since your last deploy — overwrite? [y/N]
With no local state (fresh checkout, new machine) the CLI instead asks the
server who deployed last and warns if it wasn't you. --force (or the
X-Shared-Force: 1 header) skips both checks; requests without a
prev-version header (curl, older CLIs) are accepted unchecked.
The server counts page views per site — GET requests for HTML documents,
not assets or API calls — batched in memory and flushed to meta/<site>.json
every 30 seconds, with per-day buckets kept for 30 days. Totals show up in
shared list, on the homepage, and in GET /api/sites.