feat: v2.5.0 T2 — AWS Lambda deployment (entry + SAM + workflow + docs) (#331)

- server/entry-lambda.ts: Hono app via hono/aws-lambda handle(); lazy init
  pattern for CJS compatibility and warm-start reuse; serves SPA static
  files from dist/ with MIME detection and index.html fallback
- deploy/aws-lambda/template.yaml: SAM template with Function URL (no API
  Gateway), Node 22, TURSO_* / BETTER_AUTH_SECRET / APP_URL env vars,
  minimal IAM (AWSLambdaBasicExecutionRole)
- .github/workflows/deploy-aws-lambda.yml: 8-step contract (guard upstream,
  check secrets with exact names, resolve tag, checkout, ensure SAM artifact
  bucket, apply Turso migrations, build + sam deploy, post-deploy auto-gen
  BETTER_AUTH_SECRET + patch BETTER_AUTH_URL + write URL to summary)
- package.json: build:lambda script (tsup CJS, external @libsql/client)
- docs/deploy/aws-lambda.md: Prerequisites / Secrets / Trigger /
  First-boot storage / Cost sections
- README.md, V2_ROADMAP.md: link new doc

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-22 01:28:05 -04:00
committed by GitHub
parent 5593eec3ce
commit b5be4c5ebd
8 changed files with 427 additions and 1 deletions
+208
View File
@@ -0,0 +1,208 @@
name: Deploy to AWS Lambda
on:
push:
branches: [master]
workflow_dispatch:
inputs:
version:
description: 'Release tag to deploy (e.g. v2.5.0). Leave empty for latest.'
required: false
# Prevent overlapping deployments.
concurrency:
group: deploy-aws-lambda
cancel-in-progress: false
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
# Only run on forks — the upstream repo does not deploy itself.
if: github.repository != 'saltbo/zpan'
steps:
- name: Disable upstream-only workflows
env:
GH_TOKEN: ${{ github.token }}
run: |
for workflow in ci.yml release.yml; do
gh api -X PUT "repos/${{ github.repository }}/actions/workflows/$workflow/disable" 2>/dev/null && \
echo "Disabled $workflow" || echo "$workflow already disabled"
done
- name: Check required secrets
env:
HAS_TURSO_URL: ${{ secrets.TURSO_DATABASE_URL != '' }}
HAS_TURSO_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN != '' }}
HAS_AWS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID != '' }}
HAS_AWS_SECRET: ${{ secrets.AWS_SECRET_ACCESS_KEY != '' }}
HAS_AWS_REGION: ${{ secrets.AWS_REGION != '' }}
run: |
missing=""
[ "$HAS_TURSO_URL" != "true" ] && missing="$missing TURSO_DATABASE_URL"
[ "$HAS_TURSO_TOKEN" != "true" ] && missing="$missing TURSO_AUTH_TOKEN"
[ "$HAS_AWS_KEY" != "true" ] && missing="$missing AWS_ACCESS_KEY_ID"
[ "$HAS_AWS_SECRET" != "true" ] && missing="$missing AWS_SECRET_ACCESS_KEY"
[ "$HAS_AWS_REGION" != "true" ] && missing="$missing AWS_REGION"
if [ -n "$missing" ]; then
echo "::error::Missing required secrets:$missing. Go to Settings → Secrets and variables → Actions and add them."
exit 1
fi
- name: Resolve release tag
id: release
env:
GH_TOKEN: ${{ github.token }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ -n "$INPUT_VERSION" ]; then
TAG="$INPUT_VERSION"
else
TAG=$(gh api repos/saltbo/zpan/releases/latest --jq '.tag_name')
fi
if [ -z "$TAG" ]; then
echo "::error::No release found in saltbo/zpan"
exit 1
fi
echo "version=$TAG" >> "$GITHUB_OUTPUT"
echo "### 🚀 Deploying $TAG to AWS Lambda" >> "$GITHUB_STEP_SUMMARY"
- uses: actions/checkout@v4
with:
repository: saltbo/zpan
ref: ${{ steps.release.outputs.version }}
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Setup SAM CLI
uses: aws-actions/setup-sam@v2
- run: npm ci
- name: Ensure SAM artifact bucket exists
id: bucket
run: |
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
BUCKET="zpan-sam-artifacts-${ACCOUNT}-${{ secrets.AWS_REGION }}"
if ! aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then
aws s3 mb "s3://$BUCKET" --region "${{ secrets.AWS_REGION }}"
echo "Created SAM artifact bucket: $BUCKET"
else
echo "Reusing SAM artifact bucket: $BUCKET"
fi
echo "name=$BUCKET" >> "$GITHUB_OUTPUT"
- name: Apply Turso migrations
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: npx drizzle-kit migrate
- name: Resolve existing deployment state
id: state
env:
USER_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
run: |
# Read current function config if it exists (redeployment case)
FUNC_JSON=$(aws lambda get-function-configuration --function-name zpan 2>/dev/null || echo "")
if [ -n "$FUNC_JSON" ]; then
EXISTING_SECRET=$(echo "$FUNC_JSON" | jq -r '.Environment.Variables.BETTER_AUTH_SECRET // empty')
EXISTING_URL=$(aws lambda get-function-url-config --function-name zpan \
--query FunctionUrl --output text 2>/dev/null || echo "")
fi
# Determine BETTER_AUTH_SECRET (priority: user-supplied > existing > auto-generate)
if [ -n "$USER_SECRET" ]; then
SECRET="$USER_SECRET"
echo "Using BETTER_AUTH_SECRET from GitHub secret"
elif [ -n "$EXISTING_SECRET" ]; then
SECRET="$EXISTING_SECRET"
echo "Reusing existing BETTER_AUTH_SECRET"
else
SECRET=$(openssl rand -base64 32)
echo "Auto-generated BETTER_AUTH_SECRET"
fi
echo "secret=$SECRET" >> "$GITHUB_OUTPUT"
echo "app_url=${EXISTING_URL:-}" >> "$GITHUB_OUTPUT"
- name: Build
run: |
npx vite build --mode node
# Bundle Lambda entry; @libsql/client is external (native binding)
npx tsup server/entry-lambda.ts --format cjs --outDir dist-lambda --external @libsql/client
# Assemble minimal Lambda deployment package
mkdir -p dist-lambda-pkg
cp dist-lambda/entry-lambda.cjs dist-lambda-pkg/
cp -r dist dist-lambda-pkg/
cp -r migrations dist-lambda-pkg/
# Minimal package.json so SAM installs only @libsql/client
node -e "
const p = require('./package.json');
const pkg = {
name: 'zpan-lambda',
version: p.version,
dependencies: { '@libsql/client': p.dependencies['@libsql/client'] }
};
require('fs').writeFileSync('dist-lambda-pkg/package.json', JSON.stringify(pkg, null, 2));
"
- name: SAM build
run: sam build --template-file deploy/aws-lambda/template.yaml
- name: SAM deploy
run: |
sam deploy \
--stack-name zpan \
--s3-bucket "${{ steps.bucket.outputs.name }}" \
--capabilities CAPABILITY_IAM \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
--parameter-overrides \
TursoDatabaseUrl="${{ secrets.TURSO_DATABASE_URL }}" \
TursoAuthToken="${{ secrets.TURSO_AUTH_TOKEN }}" \
BetterAuthSecret="${{ steps.state.outputs.secret }}" \
AppUrl="${{ steps.state.outputs.app_url }}"
- name: Finalize — set BETTER_AUTH_URL and write summary
run: |
# Get the actual Function URL from CloudFormation outputs
URL=$(aws cloudformation describe-stacks \
--stack-name zpan \
--query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" \
--output text)
# On first deploy AppUrl was empty; patch the live function so auth works immediately.
CURRENT_URL="${{ steps.state.outputs.app_url }}"
if [ "$URL" != "$CURRENT_URL" ]; then
aws lambda update-function-configuration \
--function-name zpan \
--environment "Variables={
NODE_ENV=production,
TURSO_DATABASE_URL=${{ secrets.TURSO_DATABASE_URL }},
TURSO_AUTH_TOKEN=${{ secrets.TURSO_AUTH_TOKEN }},
BETTER_AUTH_SECRET=${{ steps.state.outputs.secret }},
APP_URL=$URL,
BETTER_AUTH_URL=$URL
}" > /dev/null
echo "Updated BETTER_AUTH_URL → $URL"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**URL:** $URL" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Next step:** Open the URL, register the first admin account, then go to Admin → Storages to configure your S3-compatible bucket." >> "$GITHUB_STEP_SUMMARY"
+3
View File
@@ -5,6 +5,9 @@ node_modules/
dist/
dist-server/
api/
dist-lambda/
dist-lambda-pkg/
.aws-sam/
.wrangler/
# Environment
+12
View File
@@ -35,6 +35,18 @@ Deploy via GitHub Actions with zero server management. Free tier covers personal
After initial setup, the workflow runs automatically every time you sync your fork with the latest release.
### AWS Lambda
Deploy via GitHub Actions using SAM. Lambda Function URL provides HTTPS with no API Gateway needed.
1. **Fork** this repository
2. In your fork, go to **Settings → Secrets and variables → Actions** and add:
- `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` — from [Turso](https://turso.tech) (free, no credit card)
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`
3. Go to the **Actions** tab, select **Deploy to AWS Lambda**, and click **Run workflow**
See [docs/deploy/aws-lambda.md](docs/deploy/aws-lambda.md) for full setup instructions and IAM permissions.
### Docker
**Quick start** — pull the pre-built image and bring your own S3 storage:
+1 -1
View File
@@ -44,7 +44,7 @@ v2.0v2.4 ship on two runtimes. v2.5 expands to seven.
- **Cloudflare Workers** — Zero-ops, free tier covers personal use, one-click deploy (all versions)
- **Docker** — Self-hosted, bring your own S3, full control (all versions)
- **AWS Lambda** — For teams on AWS; SAM template + one-click CloudFormation (v2.5+)
- **AWS Lambda** — For teams on AWS; SAM template + GitHub Actions workflow ([docs](docs/deploy/aws-lambda.md)) (v2.5+)
- **Vercel** — One-click "Deploy to Vercel" via GitHub (v2.5+)
- **Netlify** — One-click "Deploy to Netlify" (v2.5+)
- **Azure Functions** — Bicep template; for Azure-mandated environments (v2.5+)
+63
View File
@@ -0,0 +1,63 @@
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: ZPan — S3-native file hosting platform on AWS Lambda
Globals:
Function:
Timeout: 30
MemorySize: 512
Runtime: nodejs22.x
Architectures:
- x86_64
Parameters:
TursoDatabaseUrl:
Type: String
Description: Turso database URL (e.g. libsql://your-db.turso.io)
NoEcho: true
TursoAuthToken:
Type: String
Description: Turso auth token
NoEcho: true
BetterAuthSecret:
Type: String
Description: Signing secret for auth sessions
NoEcho: true
AppUrl:
Type: String
Description: Public Function URL of the deployed application (set after first deploy)
Default: ''
Resources:
ZPanFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: zpan
CodeUri: ../../dist-lambda-pkg/
Handler: entry-lambda.handler
Description: ZPan file hosting platform
Policies:
- AWSLambdaBasicExecutionRole
Environment:
Variables:
NODE_ENV: production
TURSO_DATABASE_URL: !Ref TursoDatabaseUrl
TURSO_AUTH_TOKEN: !Ref TursoAuthToken
BETTER_AUTH_SECRET: !Ref BetterAuthSecret
APP_URL: !Ref AppUrl
BETTER_AUTH_URL: !Ref AppUrl
FunctionUrlConfig:
AuthType: NONE
Cors:
AllowOrigins:
- '*'
AllowHeaders:
- '*'
AllowMethods:
- '*'
Outputs:
FunctionUrl:
Description: ZPan public URL
Value: !GetAtt ZPanFunctionUrl.FunctionUrl
+88
View File
@@ -0,0 +1,88 @@
# AWS Lambda Deployment
ZPan runs on AWS Lambda via a [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html) — no API Gateway required. A [SAM](https://aws.amazon.com/serverless/sam/) template provisions the function and the GitHub Actions workflow deploys it automatically.
## Prerequisites
- An AWS account with permissions to create Lambda functions, IAM roles, and S3 buckets
- A [Turso](https://turso.tech) database (free tier: 9 GB, no credit card required)
- A fork of this repository
### Create a Turso database (~3 minutes)
```bash
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
```
Alternatively, create the database via the [Turso dashboard](https://app.turso.tech) without installing the CLI.
## Secrets
Add the following in your fork under **Settings → Secrets and variables → Actions**:
| Secret | Description |
|--------|-------------|
| `TURSO_DATABASE_URL` | Turso database URL, e.g. `libsql://your-db.turso.io` |
| `TURSO_AUTH_TOKEN` | Turso auth token (rotate via `turso db tokens create zpan`) |
| `AWS_ACCESS_KEY_ID` | AWS access key ID |
| `AWS_SECRET_ACCESS_KEY` | AWS secret access key |
| `AWS_REGION` | AWS region to deploy to, e.g. `us-east-1` |
> **S3 credentials are not here.** ZPan stores your object storage configuration in the database, configured via the Admin UI after the first deploy. This keeps bucket secrets off GitHub and lets you manage multiple storage backends from one place.
> **BETTER_AUTH_SECRET** — If omitted, the workflow auto-generates a secure random value on first deploy and stores it in the Lambda function configuration. Add this secret only if you want to supply your own value.
### IAM permissions for the deploy user
The AWS credentials need these permissions:
```json
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["lambda:*", "iam:*", "s3:*", "cloudformation:*"], "Resource": "*" },
{ "Effect": "Allow", "Action": "sts:GetCallerIdentity", "Resource": "*" }
]
}
```
For production, scope the `Resource` fields to specific ARNs. The workflow creates a single S3 bucket (`zpan-sam-artifacts-<account>-<region>`) for SAM deployment artifacts on first run.
## Trigger
1. Fork this repository
2. Add the secrets above
3. Go to the **Actions** tab → **Deploy to AWS Lambda****Run workflow**
The workflow runs automatically on every push to `master` after initial setup. Re-running is idempotent — it redeploys without recreating existing resources.
## First-boot storage setup
After the workflow reports success:
1. Open the Function URL shown in the job summary
2. Register a user (the first user gets admin role)
3. Go to **Admin → Storages → Add storage** and fill in your S3-compatible bucket details:
- **Endpoint**: your S3 endpoint (e.g. `https://s3.amazonaws.com` for AWS S3, or your R2/Tigris/B2 URL)
- **Bucket**: your bucket name
- **Region**: the bucket's region
- **Access Key / Secret Key**: bucket credentials
> The storage endpoint must be reachable from the **client browser**, since ZPan uploads files directly to S3 via presigned URLs — no server bandwidth is used.
## Cost
With the AWS free tier and Turso free tier, personal ZPan usage costs $0/month:
| Resource | Free tier | Notes |
|----------|-----------|-------|
| AWS Lambda | 1M requests / 400,000 GB-seconds / month | Easily covers personal use |
| Lambda Function URL | Included with Lambda | No extra charge |
| S3 (SAM artifacts) | 5 GB / month | One-time ~10 MB upload per deploy |
| Turso | 9 GB storage, 1B row reads / month | Shared across all deployments |
S3 (or R2/Tigris) for ZPan file storage is billed separately and depends on your usage. ZPan itself does not add server-side bandwidth costs because files transfer directly between client and S3.
+1
View File
@@ -8,6 +8,7 @@
"dev:node": "node --env-file=.dev.vars node_modules/vite/bin/vite.js dev --mode node",
"build": "[ \"$WORKERS_CI\" = \"1\" ] && [ \"$WORKERS_CI_BRANCH\" != \"master\" ] && export CLOUDFLARE_ENV=staging; vite build",
"build:node": "vite build --mode node && tsup server/entry-node.ts --format esm --outDir dist-server --external better-sqlite3 --external @libsql/client",
"build:lambda": "tsup server/entry-lambda.ts --format cjs --outDir dist-lambda --external @libsql/client",
"build:vercel": "vite build --mode node && tsup server/entry-vercel.ts --format esm --outDir api --external @libsql/client",
"deploy": "npm run db:migrate:d1:prod && wrangler deploy",
"db:generate": "drizzle-kit generate",
+51
View File
@@ -0,0 +1,51 @@
import { existsSync, readFileSync } from 'node:fs'
import { extname, join } from 'node:path'
import { Hono } from 'hono'
import { handle } from 'hono/aws-lambda'
import { createBootstrap } from './bootstrap'
import { createLibsqlPlatform } from './platform/libsql'
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.webp': 'image/webp',
}
// Initialized once per Lambda container; reused across warm invocations.
let cachedHandler: ReturnType<typeof handle> | undefined
async function init(): Promise<ReturnType<typeof handle>> {
if (cachedHandler) return cachedHandler
const platform = await createLibsqlPlatform({
TURSO_DATABASE_URL: process.env.TURSO_DATABASE_URL!,
TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
})
const app = await createBootstrap(platform)
const server = new Hono()
server.route('/', app)
server.get('/*', (c) => {
const filePath = join('./dist', c.req.path === '/' ? 'index.html' : c.req.path)
if (existsSync(filePath)) {
const content = readFileSync(filePath)
const mime = MIME[extname(filePath)] ?? 'application/octet-stream'
return c.body(content, 200, { 'Content-Type': mime })
}
return c.html(readFileSync('./dist/index.html', 'utf-8'))
})
cachedHandler = handle(server)
return cachedHandler
}
// biome-ignore lint/suspicious/noExplicitAny: Lambda event/context types vary by invocation model
export const handler = async (event: any, context: any) => (await init())(event, context)