v2.5 originally covered branding polish. Shift it to unlock multi-platform deployment — seven first-class targets (CF Workers, Docker, AWS Lambda, Vercel, Netlify, Azure Functions, Google Cloud Run) using Turso libSQL as the universal non-CF database. Zero SQLite-dialect work, generous free tier, HTTP protocol sidesteps serverless connection-pool issues. GitHub Actions workflows drive each deploy, mirroring the existing CF deploy.yml pattern (self-healing resources, auto-generated secrets, upstream release tracking). Object storage credentials stay in the admin UI storages table, not GitHub Secrets. Site branding (custom logo, favicon) moves to v2.8 as a managed- only white-label feature. User avatar upload remains in v2.5 as the sole carry-over from the original scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
v2.5 — Multi-Platform Deployment
Break the "Cloudflare or Docker" duality. ZPan runs on AWS Lambda, Vercel, Netlify, Azure Functions, and Google Cloud Run via a thin adapter layer, with Turso (libSQL) as the default database for all non-CF targets.
Carries over the unfinished avatar upload item from the original v2.5 scope. Site-level branding (custom logo, favicon) moves to v2.8 as a paid-tier feature.
Design Principles
- Adapter layer, not framework adoption. Extend the existing
Platforminterface and add Hono runtime adapters. No SST, no Serverless Framework, no Pulumi — those are wrong abstractions for a file host. - Turso as the universal non-CF database. Every non-CF target uses the same libSQL driver. Zero SQLite-dialect work. One Turso account serves all deployments a user owns.
- Each deploy target is self-contained.
deploy/<target>/holds one platform-native IaC file (SAMtemplate.yaml,vercel.json,netlify.toml, Bicep, Knative YAML). Fork-and-go, no cross-target magic. - GitHub Actions workflow is the primary deploy path. Each non-Docker target ships a
.github/workflows/deploy-<target>.ymlthat auto-creates resources, applies Turso migrations, generates secrets, and deploys. Native one-click buttons are deferred — they can't cover the Turso / migration / secret gap. - Storage stays universal. Existing S3-compatible abstraction is untouched. Users bring their own bucket (R2, S3, Tigris, B2, etc.).
- Free-tier friendly. Every documented target must have a path to $0/month for personal use. Cost is called out per platform in docs.
Deployment Targets
ZPan does not require a single-vendor stack. Users pick any S3-compatible bucket (R2, S3, B2, Tigris, OSS, COS…) regardless of where compute runs. Turso provides the database for all non-CF targets. This decoupling is what enables broad platform support.
Seven first-class targets. Each gets a maintained IaC template in deploy/<target>/, a GitHub Actions workflow in .github/workflows/deploy-<target>.yml, a dedicated doc page, and a CI smoke test. No tiered support, no "community-only" caveats — if it's listed here, it's officially maintained.
| # | Platform | Compute | Database | Object Storage | Notes |
|---|---|---|---|---|---|
| 1 | Cloudflare Workers | Workers | D1 | R2 (recommended) | Existing. Native D1 on CF; Turso on every other target. |
| 2 | Docker / Node self-host | Node | SQLite file or Turso | Any S3 | Existing. Ships a Dockerfile + docker-compose. |
| 3 | AWS Lambda (new) | Lambda + Function URL | Turso | Any S3 | SAM template; workflow drives sam deploy. |
| 4 | Vercel (new) | Vercel Functions (Node) | Turso | Any S3 (R2 recommended) | vercel.json; workflow drives vercel deploy --prod. |
| 5 | Netlify (new) | Netlify Functions (Node) | Turso | Any S3 (R2 recommended) | netlify.toml; workflow drives Netlify CLI deploy. |
| 6 | Azure Functions (new) | Azure Functions (Node, programming model v4) | Turso | Any S3 (external — Azure Blob is not S3-compatible) | Bicep template; workflow drives az deployment + func publish. |
| 7 | Google Cloud Run (new) | Cloud Run (Node container) | Turso | Any S3 (external — GCS is not S3-compatible) | Reuses the existing Dockerfile; workflow drives gcloud run deploy. |
All non-CF targets share one entry pattern (Hono adapter + createLibsqlPlatform). Cloud Run reuses the Docker entry unchanged; Azure uses its own handler model.
Database Strategy
Turso (libSQL) is the default for every non-CF deployment. Rationale:
- Zero dialect work. libSQL is SQLite. The existing
drizzle-orm/sqlite-coreschema, the 13 existing migration SQL files, and every service-layer query run unchanged. - HTTP protocol. No connection pool exhaustion under Lambda/Vercel concurrency spikes — the structural problem that makes traditional Postgres/MySQL painful on serverless.
- Generous free tier. 9 GB total storage, 500 databases, 1B row reads / 25M row writes per month. No credit card required. Larger than CF D1's free tier.
- One account → every deployment. A user deploying to AWS + Vercel + a laptop Docker run uses one Turso DB (or branches it) — not three separate DB accounts.
Explicit tradeoffs:
- Turso is a third-party SaaS. If it has an outage, non-CF ZPan deployments go down with it. CF deployments are unaffected (D1 is used there).
- Users bringing their own Postgres (Neon, Aurora, Supabase) are not supported in v2.5. Postgres dialect is future work (see Out of Scope).
- Docker users can keep using local SQLite files — Turso is optional there.
User-side Turso setup (documented in each target's deploy doc)
Three steps, ~3 minutes, no credit card:
curl -sSfL https://get.tur.so/install.sh | bash
turso auth signup # GitHub OAuth
turso db create zpan
turso db show zpan --url # → TURSO_DATABASE_URL
turso db tokens create zpan # → TURSO_AUTH_TOKEN
Alternative: web dashboard at turso.tech (no CLI install).
Deployment Automation
Each non-Docker target ships a GitHub Actions workflow as the primary deploy path. Docker users run docker compose up directly.
Why workflows, not one-click buttons
Native one-click deploy buttons (Deploy to Vercel, Launch Stack, Deploy to Azure…) are limited to deploying code + in-platform resources. None of them can:
- Create the external Turso database
- Apply drizzle migrations to Turso
- Auto-generate
BETTER_AUTH_SECRETon first deploy - Create platform-side prerequisites (SAM deployment-artifact S3 bucket, Azure Resource Group, etc.)
- Track upstream ZPan releases so forks auto-update
The existing deploy.yml for Cloudflare (which creates D1 if missing, applies migrations, auto-generates BETTER_AUTH_SECRET, and checks out the latest saltbo/zpan release tag) already proves the workflow-based pattern. v2.5 replicates this pattern across the 5 new targets.
Workflow contract (uniform across targets)
Every .github/workflows/deploy-<target>.yml follows the same 8-step shape:
- Guard upstream —
if: github.repository != 'saltbo/zpan'so the upstream repo doesn't auto-deploy itself - Check required secrets — fail early with an actionable error if any secret is missing
- Resolve upstream release tag — default: latest release from
saltbo/zpan; override:workflow_dispatchinput - Checkout upstream code at the resolved tag — the fork holds only deployment config; code always comes from upstream
- Ensure backing resources exist (idempotent) — platform-specific create-if-missing: SAM deployment-artifact S3 bucket, D1 database, Function App, Lambda role, Resource Group, etc. These are platform infrastructure, not user file storage.
- Apply Turso migrations —
drizzle-kit migrateagainst the user's Turso DB, fail fast on conflict. Separate step from resource provisioning so failure modes are distinguishable in the run log. - Build + Deploy — platform-native CLI (
sam deploy,vercel deploy,az deployment,gcloud run deploy,wrangler deploy) - Post-deploy — auto-generate
BETTER_AUTH_SECRETon first deploy; write the app URL toGITHUB_STEP_SUMMARY
User-facing setup (uniform)
Every target's docs follow this 3-step flow:
-
Fork the repo.
-
Add GitHub Secrets — target-specific subset of:
Secret Required by Notes TURSO_DATABASE_URLall non-CF From Turso dashboard or CLI TURSO_AUTH_TOKENall non-CF Rotate via turso db tokens createCLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_IDCF Workers Existing AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGIONAWS Lambda OIDC alternative documented VERCEL_TOKEN,VERCEL_ORG_ID,VERCEL_PROJECT_IDVercel — NETLIFY_AUTH_TOKEN,NETLIFY_SITE_IDNetlify — AZURE_CREDENTIALSAzure Functions Service principal JSON GCP_SERVICE_ACCOUNT_KEY,GCP_PROJECT_IDCloud Run — Note on S3 credentials. S3 / R2 / OSS / COS bucket configuration is not a GitHub Secret. ZPan stores storage credentials in the
storagesdatabase table, configured via the admin UI after the first deploy. This keeps bucket secrets off both GitHub and the workflow logs, and lets a single ZPan instance manage multiple storage backends. -
Trigger deploy —
git push origin masteror Actions tab → Run workflow.
First push creates resources and deploys from the latest upstream release. Subsequent pushes redeploy. Docker users skip all of this and run docker compose up.
First-boot storage setup (all targets)
After the workflow reports "deploy succeeded", the user:
- Opens the deployed URL and completes first-admin signup
- Goes to Admin → Storages → Add storage
- Fills in bucket name, endpoint, region, access key, secret key
- Saves — ZPan verifies the bucket is reachable and marks the storage
active
This is the same flow existing CF and Docker users follow today. The workflow-based deploy doesn't change it.
Native one-click buttons — deferred to v2.5.x polish
Vercel / Netlify / Cloud Run have genuinely pleasant native deploy buttons with preview-deploy UX that GitHub Actions can't match. Adding them as an optional alternative path (alongside the primary workflow) is valuable polish — but only for those three targets. Deferred to a post-v2.5 patch release based on demand.
AWS "Launch Stack", Azure "Deploy to Azure", and CF "Deploy to Cloudflare" buttons are too limited (can't fetch code, can't provision Turso, can't apply migrations) to offer as viable alternatives — those three stay workflow-only.
Architecture
New files
server/platform/
libsql.ts # new — @libsql/client + drizzle libSQL driver
interface.ts # unchanged shape
server/
entry-lambda.ts # new — hono/aws-lambda
entry-vercel.ts # new — hono/vercel
entry-netlify.ts # new — hono/netlify
entry-azure.ts # new — hono/azure-functions (programming model v4)
azure-host.json # new — Azure Functions runtime manifest; copied next to
# the build output during build:azure (not an IaC file)
entry-node.ts # existing — used by Docker AND Google Cloud Run
# picks DB at runtime: Turso if TURSO_DATABASE_URL set,
# else local SQLite file (current Docker behavior)
(workers/bootstrap.ts) # existing — CF Workers fetch handler
deploy/
docker-compose.yml # existing
docker-compose.rustfs.yml # existing
aws-lambda/
template.yaml # SAM template
vercel/
vercel.json
netlify/
netlify.toml
azure-functions/
main.bicep # Bicep template (Resource Group / Storage / Function App)
cloud-run/
service.yaml # Knative service definition
.github/workflows/
deploy.yml # existing — CF Workers
deploy-aws-lambda.yml # new
deploy-vercel.yml # new
deploy-netlify.yml # new
deploy-azure.yml # new
deploy-cloud-run.yml # new
docs/deploy/
cloudflare.md # existing content, consolidated
docker.md # existing content, consolidated
aws-lambda.md
vercel.md
netlify.md
azure-functions.md
cloud-run.md
Platform adapter
libsql.ts mirrors the shape of cloudflare.ts and node.ts:
export function createLibsqlPlatform(env: Record<string, string>): Platform {
const client = createClient({
url: env.TURSO_DATABASE_URL!,
authToken: env.TURSO_AUTH_TOKEN,
})
return {
db: drizzle(client, { schema }),
getEnv: (key) => env[key],
}
}
Shared by Lambda / Vercel / Netlify entry points — no per-target DB code.
Build outputs
package.json adds:
"build:lambda": "tsup server/entry-lambda.ts --format cjs --outDir dist-lambda"
"build:vercel": "tsup server/entry-vercel.ts --format esm --outDir api"
"build:netlify": "tsup server/entry-netlify.ts --format esm --outDir netlify/functions"
"build:azure": "tsup server/entry-azure.ts --format esm --outDir azure-functions"
Google Cloud Run uses the existing docker build / Dockerfile pipeline — no separate entry or build command.
Migrations across targets
- CF Workers:
wrangler d1 migrations apply(existing). - Docker SQLite:
drizzle-kit migrateagainst local file (existing). - Turso (Lambda / Vercel / Netlify / Azure / Cloud Run / Docker-turso):
drizzle-kit migratewithTURSO_DATABASE_URL+TURSO_AUTH_TOKEN. Run inside each target's deploy workflow (step 6 of the workflow contract), so users never touch a migration CLI locally.
No new migration files. No dual-dialect maintenance.
CI matrix
Extend Vitest configs:
*.test.ts— Node + better-sqlite3 (existing)*.cf-test.ts— workerd + D1 (existing)*.libsql-test.ts(new) — Node + libsql local file mode, exercises the libsql driver path
No test should be dialect-specific (schema is pure SQLite). The libsql suite is a smoke check that the driver wiring works.
Per-target Deploy Specifics
Each docs/deploy/<target>.md follows the same 4-section template:
- Prerequisites — Turso DB, GitHub fork, platform-specific credentials
- Add GitHub Secrets — target-specific list
- Trigger deploy — push to
masteror Actions → Run workflow - First-boot storage setup — sign up as admin, add an S3 bucket via Admin → Storages
- Configuration & cost — custom domain, SSL, free-tier limits
AWS Lambda
- IaC:
deploy/aws-lambda/template.yaml(SAM — Function URL, no API Gateway) - Workflow creates: SAM deployment-artifact S3 bucket (holds the Lambda zip — separate from user file storage), Lambda role, Lambda function, Function URL
- Runtime: Node 22
- Deploy CLI (inside workflow):
sam deploy - Cost: Lambda free 1M requests/month; Turso free; S3 minimal; no RDS/Aurora
Vercel
- IaC:
deploy/vercel/vercel.json(Node runtime, not Edge — we need Node APIs for@aws-sdk/client-s3) - Workflow creates: Vercel project if missing
- Deploy CLI (inside workflow):
vercel deploy --prod --token=$VERCEL_TOKEN - Cost: Hobby plan free for non-commercial; Pro $20/mo for commercial
Netlify
- IaC:
deploy/netlify/netlify.toml - Workflow creates: Netlify site if missing
- Deploy CLI (inside workflow):
netlify deploy --prod --site $NETLIFY_SITE_ID - Cost: Free tier 125k req/month; Pro $19/mo
Azure Functions
- IaC:
deploy/azure-functions/main.bicep(Resource Group + Storage Account + Consumption plan + Function App) - Workflow creates: full resource group via
az deployment group create - Runtime: Azure Functions programming model v4 on Node 22
- Deploy CLI (inside workflow):
func azure functionapp publish <name> - Object storage: external S3/R2 (Azure Blob is not S3-compatible — we don't adapt)
- Cost: consumption plan 1M req/month free; add Turso + chosen S3
Google Cloud Run
- IaC:
deploy/cloud-run/service.yaml(Knative service manifest) - Workflow builds: Docker image via Cloud Build (or pre-built image from GHCR)
- Runtime: Node container, reuses the existing root-level
Dockerfile - Deploy CLI (inside workflow):
gcloud run deploy zpan --source . - Object storage: external S3/R2 (GCS is not S3-compatible)
- Cost: 2M req/month free + 360,000 GB-seconds; add Turso + chosen S3
Cloudflare Workers (existing, for reference)
- IaC:
wrangler.toml - Workflow: existing
.github/workflows/deploy.yml - Creates: D1 database (reuses if exists), applies migrations, generates
BETTER_AUTH_SECRET - Cost: Workers free 100K req/day (Paid $5/mo for 10M); D1 free 5 GB
Docker (existing, for reference)
- IaC:
Dockerfile+deploy/docker-compose.yml - No workflow — user runs
docker compose pull && docker compose up -d - Cost: VPS cost only
Avatar Upload (carried over from original v2.5)
The one user-level polish item still in v2.5 scope.
- Authenticated users upload a personal avatar via Settings → Profile
- Stored under
_system/avatars/<userId>.*in the workspace's S3 bucket - Writes to
user.image(existing better-auth field) - Replaces the current fallback-initials circle
Not in v2.5 (moved or already done)
- ✅ Dark mode — already shipped (
next-themesintegrated,/settings/appearancepage exists) - ✅ Site name / description — already shipped (
use-site-options) - ✅ Custom file access domain — subsumed by v2.4 Image Hosting custom domain
- 💰 Custom logo / favicon / site branding — moved to v2.8 Managed Service as a paid-tier feature. Self-host stays on the default ZPan brand.
User Scenarios
Indie dev with an AWS account:
I fork ZPan on GitHub, add five secrets (AWS creds, Turso URL + token), push to master. The deploy-aws-lambda workflow creates my Lambda function, wires up the Function URL, applies Turso migrations, and auto-generates
BETTER_AUTH_SECRET. Ten minutes later I have a live URL in the workflow's run summary. I open it, sign up as first admin, and add my S3 bucket via the Storage settings page. Upgrades are automatic — the workflow pulls the latest upstream release on every push.
Vercel-native blogger:
Fork, add Turso + Vercel secrets, push. The deploy-vercel workflow provisions my Vercel project, runs migrations, deploys. My personal image host is live at
zpan-mine.vercel.appin under 5 minutes. I sign up, paste my Cloudflare R2 bucket credentials into Admin → Storages, and start uploading. Later I mapimg.myblog.comvia Vercel's dashboard.
Self-host veteran:
I run ZPan in Docker on my NAS with a local SQLite file — no Turso, no external DB, no GitHub. When I later get a second machine, I point both at the same Turso URL and now I have shared state across devices.
Small team on AWS:
We use the workflow path from a private fork. Our ops engineer appreciates that the runtime is stateless, auto-scales with Lambda concurrency, and doesn't require managing an RDS instance. CI/CD lives entirely in the fork's GitHub Actions.
Enterprise on Azure:
Company policy mandates Azure. We fork ZPan, add Azure service-principal credentials + Turso creds, push. The deploy-azure workflow provisions a Resource Group + Function App via Bicep, deploys the code, applies migrations. After deploy, the admin configures an R2 bucket in Admin → Storages. Compute lives inside our Azure subscription for SSO and billing; storage is a decoupled R2 bucket.
GCP-native startup:
We fork, add GCP service account JSON + Turso creds, push. The deploy-cloud-run workflow runs
gcloud run deploy --source .— Cloud Build handles the container image, we get a stable URL back. We sign up as admin, then point ZPan at the R2 bucket we already use for the public site via Admin → Storages.
Explicitly Out of Scope
Anything not in the seven targets above is not supported in v2.5. Notable explicit exclusions:
- Alibaba Cloud FC / Tencent SCF — Turso latency from Chinese networks is unacceptable. Unlocking these requires MySQL dialect support, ICP compliance docs, and OSS/COS presigned-URL compatibility. Scoped as v2.6 China Cloud Deployment (or whenever MySQL dialect lands).
- Supabase, AWS Aurora, Neon, Vercel Postgres as native DBs — require Postgres dialect across drizzle schemas (~1.5 weeks + ongoing dual-schema maintenance tax). Deferred to v2.7+; they unlock simultaneously when the dialect layer lands.
- Azure Blob / Google Cloud Storage as native object storage — both are non-S3-compatible. Users on Azure Functions or Cloud Run bring an external S3/R2 bucket. Writing dedicated storage drivers for each is not justified by demand.
- Deno Deploy, Fastly Compute, AWS Lambda@Edge, Lagon — runtime or platform constraints (WASM, Deno porting, 1 MB package limit, archived project) make them uneconomical relative to the audience.
- Unified IaC layer (SST / Pulumi / Terraform) — extra abstraction without benefit. Platform-native templates (SAM, Bicep, Knative,
vercel.json…) are better-documented and easier to fork.
v1 Issues Resolved
- #123 Avatar storage — user avatars uploaded to S3 via Settings → Profile
Success Metrics
- 7 deployment targets, each with a GitHub Actions workflow (5 new) or equivalent (Docker
docker compose up, CF existingdeploy.yml) - Each workflow is idempotent: re-running on an existing deployment redeploys without touching backing resources
- Each workflow auto-generates
BETTER_AUTH_SECRETon first deploy, applies Turso migrations, and writes the deployed URL toGITHUB_STEP_SUMMARY - Each non-CF target passes a CI smoke test that deploys, seeds a Turso DB, uploads + fetches a file, and tears down
- Users can stand up a production-ready ZPan in under 10 minutes on any target by forking + adding secrets + pushing — no CLI tools required on their machine except what each platform's CI-provided runner already has