Backend API and infrastructure for Cloudisy — a multi-tenant static-site hosting platform. Each user project is served as a subdomain (mysite.cloudisy.com) directly from MinIO object storage via a custom Caddy plugin, with zero per-tenant configuration.
Client
│
▼
Caddy (static_s3 plugin)
│ 1. Extract subdomain from Host header
│ 2. Redis GET "site:{subdomain}" → UUID (5 min TTL)
│ miss → SELECT id FROM sites WHERE subdomain=$1 AND active=true
│ 3. Stream file from MinIO: cloudisy-sites/{UUID}/{path}
│
├──▶ MinIO (object storage — single shared bucket)
│
└──▶ Express API (site management, uploads, analytics)
│
├── PostgreSQL (sites, pages, site_daily_stats)
├── Redis (tenant UUID cache, usage counters, BullMQ queues)
└── BullMQ Workers
├── upload — extracts zip → stores to MinIO under {UUID}/{file}
├── sync — flushes Redis usage counters → PostgreSQL
└── log — processes access logs from Vector
| Topic | Decision |
|---|---|
| Routing | Caddy static_s3 plugin resolves subdomain → UUID → S3 key at request time. No Caddy admin API calls needed. |
| Storage | Single shared bucket cloudisy-sites. Every tenant's files live under {site_id}/. |
| Analytics | The plugin writes site_daily_stats rows to PostgreSQL directly (Redis → PG flush every 5 min). |
| Auth | JWT verified via remote JWKS at https://cloudisy.vercel.app/api/auth/jwks. |
| Container | Image / Build | Role |
|---|---|---|
express_app |
./Dockerfile |
REST API |
caddy_server |
./plugins/Dockerfile |
Caddy + static_s3 plugin |
minio_server |
minio/minio |
S3-compatible object storage |
postgres_db |
postgres:16-alpine |
Primary database |
redis |
redis:7-alpine |
Cache + BullMQ broker |
upload_w |
./Dockerfile |
Upload queue worker |
sync_w |
./Dockerfile |
Usage sync worker |
log_w |
./Dockerfile |
Log ingestion worker |
vector |
timberio/vector |
Caddy access log pipeline |
Resolved by the Caddy plugin at request time.
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key — used as the MinIO key prefix |
subdomain |
TEXT UNIQUE | e.g. mysite for mysite.cloudisy.com |
active |
BOOLEAN | Set to false on project deletion; caddy stops routing immediately |
created_at |
TIMESTAMPTZ |
Tenant project metadata managed by the API.
| Column | Type | Notes |
|---|---|---|
id |
UUID | Primary key |
site_id |
UUID FK → sites | Links to the routing/storage record |
tenant_id |
TEXT | Auth provider user ID |
tenant_name |
TEXT | |
domain |
TEXT | Full domain e.g. mysite.localhost |
project_name |
TEXT | Subdomain slug |
plan |
TEXT | free (default) |
request |
BIGINT | Cumulative request count (synced from Redis) |
bandwidth_usage |
BIGINT | Cumulative bandwidth in bytes |
Written by the Caddy static_s3 plugin's analytics middleware.
| Column | Type | Notes |
|---|---|---|
site_id |
UUID FK → sites | |
date |
DATE | |
requests |
BIGINT | Total requests |
bandwidth |
BIGINT | Total bytes transferred |
requests_2xx / 3xx / 4xx / 5xx |
BIGINT | HTTP status breakdown |
humans / bots |
BIGINT | User-agent classification |
unique_ips |
BIGINT | HyperLogLog cardinality estimate |
peak_hour |
TEXT | YYYY-MM-DD:HH |
All protected endpoints require a Bearer <JWT> header.
GET /health
Returns { message: "ok" }. No auth required.
POST /api/pages/create
Authorization: Bearer <token>
Body: { "project_name": "mysite" }
- Inserts a row into
sites(generates the UUID used as the MinIO prefix) - Inserts a row into
pages - The Caddy plugin immediately starts routing
mysite.{BASE_DOMAIN}with no additional config
Response:
{
"id": "...",
"site_id": "<uuid>",
"project_name": "mysite",
"domain": "mysite.localhost",
"tenant_id": "...",
...
}GET /api/pages
Authorization: Bearer <token>
GET /api/pages/usage/:domain
Authorization: Bearer <token>
Returns combined DB + live Redis counters:
{
"requests": { "used": 1234, "limit": 100000 },
"bandwidth": { "used_gb": "0.001234", "limit": "1GB" }
}DELETE /api/pages/:id
Authorization: Bearer <token>
- Deletes all MinIO objects under
{site_id}/ - Sets
sites.active = falseand deletessite:subdomainfrom Redis (Caddy stops routing instantly) - Deletes the
pagesrow (cascades tosite_daily_stats)
Deploy a build by uploading a .zip file containing your static site output.
POST /upload/:pageId
Authorization: Bearer <token>
Content-Type: multipart/form-data
Field: file (zip archive, max 250 MB)
- Verifies the caller owns the page
- Enqueues a BullMQ job that extracts the zip and uploads each file to:
cloudisy-sites/{site_id}/{filepath}
GET /upload/status/:jobId
Authorization: Bearer <token>
Returns { jobId, state, failedReason }.
state is one of: waiting, active, completed, failed.
POST /internal/log
Used by the Vector log pipeline to ingest Caddy access logs. Not authenticated with JWT (internal network only).
Copy env to .env before starting.
| Variable | Default | Description |
|---|---|---|
DB |
— | PostgreSQL DSN (used by the app) |
DRIZZLE_CONNECTION |
— | PostgreSQL DSN for Drizzle Kit migrations |
REDIS_URL |
redis://redis:6379 |
Redis connection URL |
MINIO_ENDPOINT |
minio_server |
MinIO hostname |
MINIO_PORT |
9000 |
MinIO port |
MINIO_ACCESS_KEY |
minioadmin |
MinIO access key |
MINIO_SECRET_KEY |
minioadmin |
MinIO secret key |
MINIO_BUCKET |
cloudisy-sites |
Shared bucket name |
BASE_DOMAIN |
localhost |
Base domain for subdomain routing (e.g. cloudisy.com) |
POSTGRES_USER/PASSWORD/DB |
— | PostgreSQL init vars |
- Docker + Docker Compose
- Go (only needed to build the Caddy plugin — handled inside Docker)
git clone --recurse-submodules <repo-url>
cd cloudisy_server
# If already cloned without submodules:
git submodule update --init --recursivecp env .env
# Edit BASE_DOMAIN, DB credentials, MinIO keys, etc.docker compose up --buildFirst run: Docker builds Caddy from
plugins/Dockerfile(compiles thestatic_s3Go plugin). This takes ~2 minutes but is cached on subsequent builds.
Drizzle migrations are run by the migrator service automatically before the app starts.
To run manually:
pnpm run migrateThe plugins/ directory is a Git submodule pointing to Mahadi-rsio/cdx_s3.
The plugin (static_s3) handles:
- Multi-tenant routing — resolves
subdomain → site UUIDvia Redis cache + PostgreSQL - SPA routing — serves
index.htmlas fallback for unknown paths - Caching — LRU cache for small assets, conditional request support (ETag / Last-Modified)
- Media redirect — redirects large media files directly to MinIO (saves VPS bandwidth)
- Analytics — writes per-site daily stats to
site_daily_statsusing Redis counters flushed every 5 minutes
The Caddyfile at config/Caddyfile uses a wildcard site block:
*.{$BASE_DOMAIN} {
static_s3 { ... }
}
No Caddy admin API is used — every new site starts being served the moment a row is inserted into the sites table.
# Install dependencies
pnpm install
# Type check
npx tsc --noEmit
# Build
pnpm run build
# Run locally (requires external services)
pnpm run devcloudisy_server/
├── config/
│ ├── Caddyfile # Caddy static_s3 plugin config (wildcard routing)
│ └── vector.toml # Vector log pipeline config
├── plugins/ # Git submodule — Caddy static_s3 plugin (Go)
├── src/
│ ├── app.ts # Express app setup, rate limiting
│ ├── server.ts # Server entrypoint, startup tasks
│ ├── constants/ # Shared constants (domain, limits, cron)
│ ├── controllers/ # Request handlers
│ ├── infrastructure/
│ │ ├── cache/ # Redis client
│ │ ├── db/ # Drizzle ORM (db.ts, schema.ts)
│ │ ├── proxy/ # caddy.ts (stub — no longer used)
│ │ └── storage/ # MinIO client, shared bucket helpers
│ ├── middleware/ # JWT auth middleware
│ ├── queue/
│ │ ├── jobs/ # BullMQ queue definitions
│ │ └── workers/ # upload, sync, log workers
│ ├── routes/ # Express routers
│ ├── services/ # Business logic (page, upload, sync)
│ ├── types/
│ ├── utils/
│ └── validators/ # Zod schemas
├── docker-compose.yml
├── Dockerfile # Node.js app image
└── drizzle.config.ts