Files
zpan/docs/roadmap/v2.5.md
T
saltbo 5bd29b5e8c docs(roadmap): refocus v2.5 on multi-platform deployment via Turso
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>
2026-04-21 23:55:05 -04:00

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 Platform interface 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 (SAM template.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>.yml that 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:

  1. Zero dialect work. libSQL is SQLite. The existing drizzle-orm/sqlite-core schema, the 13 existing migration SQL files, and every service-layer query run unchanged.
  2. HTTP protocol. No connection pool exhaustion under Lambda/Vercel concurrency spikes — the structural problem that makes traditional Postgres/MySQL painful on serverless.
  3. 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.
  4. 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_SECRET on 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:

  1. Guard upstreamif: github.repository != 'saltbo/zpan' so the upstream repo doesn't auto-deploy itself
  2. Check required secrets — fail early with an actionable error if any secret is missing
  3. Resolve upstream release tag — default: latest release from saltbo/zpan; override: workflow_dispatch input
  4. Checkout upstream code at the resolved tag — the fork holds only deployment config; code always comes from upstream
  5. 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.
  6. Apply Turso migrationsdrizzle-kit migrate against the user's Turso DB, fail fast on conflict. Separate step from resource provisioning so failure modes are distinguishable in the run log.
  7. Build + Deploy — platform-native CLI (sam deploy, vercel deploy, az deployment, gcloud run deploy, wrangler deploy)
  8. Post-deploy — auto-generate BETTER_AUTH_SECRET on first deploy; write the app URL to GITHUB_STEP_SUMMARY

User-facing setup (uniform)

Every target's docs follow this 3-step flow:

  1. Fork the repo.

  2. Add GitHub Secrets — target-specific subset of:

    Secret Required by Notes
    TURSO_DATABASE_URL all non-CF From Turso dashboard or CLI
    TURSO_AUTH_TOKEN all non-CF Rotate via turso db tokens create
    CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID CF Workers Existing
    AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION AWS Lambda OIDC alternative documented
    VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID Vercel
    NETLIFY_AUTH_TOKEN, NETLIFY_SITE_ID Netlify
    AZURE_CREDENTIALS Azure Functions Service principal JSON
    GCP_SERVICE_ACCOUNT_KEY, GCP_PROJECT_ID Cloud Run

    Note on S3 credentials. S3 / R2 / OSS / COS bucket configuration is not a GitHub Secret. ZPan stores storage credentials in the storages database 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.

  3. Trigger deploygit push origin master or 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:

  1. Opens the deployed URL and completes first-admin signup
  2. Goes to Admin → Storages → Add storage
  3. Fills in bucket name, endpoint, region, access key, secret key
  4. 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 migrate against local file (existing).
  • Turso (Lambda / Vercel / Netlify / Azure / Cloud Run / Docker-turso): drizzle-kit migrate with TURSO_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:

  1. Prerequisites — Turso DB, GitHub fork, platform-specific credentials
  2. Add GitHub Secrets — target-specific list
  3. Trigger deploy — push to master or Actions → Run workflow
  4. First-boot storage setup — sign up as admin, add an S3 bucket via Admin → Storages
  5. 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-themes integrated, /settings/appearance page 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.app in under 5 minutes. I sign up, paste my Cloudflare R2 bucket credentials into Admin → Storages, and start uploading. Later I map img.myblog.com via 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 existing deploy.yml)
  • Each workflow is idempotent: re-running on an existing deployment redeploys without touching backing resources
  • Each workflow auto-generates BETTER_AUTH_SECRET on first deploy, applies Turso migrations, and writes the deployed URL to GITHUB_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