Paste a GitHub URL → get a live static site on its own subdomain.
https://{id}.zellr.nikhilwho.in
Zellr is a small Vercel-style static deploy platform. You submit a public GitHub repo; Zellr clones it, builds it (npm install && npm run build), stores the output in Cloudflare R2, and serves it on a unique subdomain.
The live product hostnames:
| Hostname | Service |
|---|---|
zellr.nikhilwho.in |
Dashboard (Next.js web) |
api.zellr.nikhilwho.in |
Upload API |
{id}.zellr.nikhilwho.in |
Deployed site (request-handler) |
Example: after a deploy with id 6g3xo, the site is at:
https://6g3xo.zellr.nikhilwho.in/
Locally (bypassing nginx) the same site is:
http://6g3xo.zellr.nikhilwho.in:3001/
or http://localhost:3001/ with Host: 6g3xo.zellr.nikhilwho.in.
GitHub URL
│
▼
┌─────────────┐ POST /upload ┌──────────────────┐
│ Web (:3000)│ ─────────────────▶ │ Upload service │
└─────────────┘ │ (api :4000) │
└────────┬─────────┘
│
1. generate 5-char id (e.g. 6g3xo)
2. git clone → upload source to R2
key prefix: output/{id}/
3. Redis LPUSH build-queue
HSET status {id} = uploaded
│
▼
┌──────────────────┐
│ Deploy service │
│ (background) │
└────────┬─────────┘
│
4. BRPOP build-queue
5. download output/{id}/
6. npm install && npm run build
7. upload dist/ → R2 dist/{id}/
8. HSET status {id} = deployed
│
▼
┌──────────────────┐ Host: 6g3xo.… ┌──────────────────┐
│ Browser request │ ──────────────────▶ │ Request handler │
│ to subdomain │ │ (:3001) │
└──────────────────┘ └────────┬─────────┘
│
id = hostname.split(".")[0] → 6g3xo
GET R2 object dist/6g3xo/index.html
(or dist/6g3xo{path} for assets)
Redis status field |
Meaning |
|---|---|
| (missing) | Unknown id |
uploaded |
Source in R2, waiting for / in build |
deployed |
Built assets in dist/{id}/, ready to serve |
The landing page polls GET /status?id={id} every 2s until status is deployed, then shows https://{id}.{NEXT_PUBLIC_SITE_DOMAIN}.
For http://6g3xo.zellr.nikhilwho.in:3001/:
- DNS / nginx —
*.zellr.nikhilwho.inpoints at the host; nginx matches the wildcard and proxies to127.0.0.1:3001(request-handler). Hitting:3001directly skips nginx but uses the same handler. - ID extraction —
req.hostnameis6g3xo.zellr.nikhilwho.in→ first label →6g3xo. - Path —
/becomes/index.html. - R2 fetch — object key
dist/6g3xo/index.html. - MIME —
.html/.css/.jsmapped; everything elseapplication/octet-stream.
Assets like /assets/index-abc.js resolve to dist/6g3xo/assets/index-abc.js.
Build assumption: the repo must produce a dist/ folder (Vite / CRA-style). That folder is what gets uploaded and served.
apps/
├── web/ # Next.js landing — submit repo, poll status
├── upload-service/ # Express API — clone, upload source, enqueue
├── deploy-service/ # Worker — build + upload dist
├── request-handler/ # Express — subdomain → R2 static files
└── doc/ # Product docs site (optional, not in compose)
Shared plumbing: Redis (build-queue list + status hash) and Cloudflare R2 (S3-compatible).
| Service | Host bind | Container |
|---|---|---|
| web | 127.0.0.1:3000 |
:3000 |
| upload-service | 127.0.0.1:4000 |
:3000 |
| request-handler | 127.0.0.1:3001 |
:3001 |
| deploy-service | — | worker only |
| redis | internal | :6379 |
Nginx (nginx/zellr.conf) fronts production:
zellr.nikhilwho.in→ web:3000api.zellr.nikhilwho.in→ upload:4000*.zellr.nikhilwho.in→ request-handler:3001
- Node.js 18+
- Docker (recommended) or a local Redis
- Cloudflare R2 credentials (S3 API token)
cp .env.example .env
# fill ACCESS_KEY_ID, SECRET_ACCESS_KEY, ENDPOINT, R2_BUCKET_NAME
# set NEXT_PUBLIC_UPLOAD_API_URL / NEXT_PUBLIC_SITE_DOMAIN for your envdocker compose up --build- Dashboard: http://localhost:3000
- Upload API: http://localhost:4000
- Sites: http://localhost:3001 (send a
Hostheader with{id}.zellr.nikhilwho.in)
Browsers need DNS. On your Mac there is no real *.zellr.nikhilwho.in yet, so map the deploy id to localhost with /etc/hosts, then open port 3001 (request-handler; no nginx locally).
# after deploy status is "deployed" (replace ohu8a with your id)
curl "http://127.0.0.1:4000/status?id=ohu8a"
# fake DNS for that subdomain only (macOS has no wildcard /etc/hosts)
sudo sh -c 'echo "127.0.0.1 ohu8a.zellr.nikhilwho.in" >> /etc/hosts'
# open in browser
open "http://ohu8a.zellr.nikhilwho.in:3001/"Or skip hosts and use curl:
curl -H "Host: ohu8a.zellr.nikhilwho.in" http://127.0.0.1:3001/In production, one DNS record *.zellr.nikhilwho.in → EC2 replaces /etc/hosts, and nginx serves on 80/443 (no :3001 in the URL).
redis-server # separate terminal
npm install
npm run dev # all apps via TurboUseful filters:
npx turbo run dev --filter=web
npx turbo run dev --filter=upload-serviceRoot scripts: npm run build · npm run lint · npm run clean
| Method | Path | Body / query | Service |
|---|---|---|---|
POST |
/upload |
{ "repoUrl": "https://github.com/…" } |
upload-service |
GET |
/status?id={id} |
— | upload-service |
POST /upload returns { message, id }. After deploy, open https://{id}.zellr.nikhilwho.in.
| Prefix | Contents |
|---|---|
output/{id}/ |
Cloned source (pre-build) |
dist/{id}/ |
Built static assets (served) |
See .env.example.
| Variable | Used by | Purpose |
|---|---|---|
ACCESS_KEY_ID / SECRET_ACCESS_KEY / ENDPOINT / R2_BUCKET_NAME |
upload, deploy, request-handler | R2 access |
REDIS_URL |
upload, deploy | Queue + status |
NEXT_PUBLIC_UPLOAD_API_URL |
web (build-time) | e.g. https://api.zellr.nikhilwho.in |
NEXT_PUBLIC_SITE_DOMAIN |
web (build-time) | e.g. zellr.nikhilwho.in |