Complete setup guide for deploying the webmail app from scratch using Cloudflare (R2 + Workers) and GitHub Actions.
flowchart LR
A["pnpm release"] --> B["np: lint, test, build, bump, tag"] --> C["GitHub Release published"]
C --> D["Deploy workflow: build, R2 sync, Worker deploy, cache purge"]
D --> E["Result: Static PWA served from Cloudflare edge"]
- Cloudflare account with a domain configured
- GitHub repository with Actions enabled
- Node.js 20+ and pnpm 9+ installed locally
Cloudflare Dashboard → R2 Object Storage → Create bucket
| Setting | Value |
|---|---|
| Name | webmail-prod |
| Location | Default |
R2 → Manage R2 API Tokens → Create API token
| Setting | Value |
|---|---|
| Token name | webmail-deploy |
| Permissions | Object Read & Write |
| Bucket scope | Your bucket |
| TTL | No expiry (for CI/CD) |
Save these credentials:
| Credential | Secret Name |
|---|---|
| Access Key ID | R2_ACCESS_KEY_ID |
| Secret Access Key | R2_SECRET_ACCESS_KEY |
Cloudflare Dashboard → Any domain → Overview → Right sidebar
| ID | Secret Name |
|---|---|
| Account ID | R2_ACCOUNT_ID |
| Zone ID | CLOUDFLARE_ZONE_ID |
My Profile → API Tokens → Create Token → Custom token
| Permission | Access |
|---|---|
| Account / Workers Scripts | Edit |
| Account / Workers R2 Storage | Edit |
| Zone / Cache Purge | Purge |
| Zone / Workers Routes | Edit |
- Zone Resources: Include → Specific zone → Your domain
- Account Resources: Include → Your account
- Save the token →
CLOUDFLARE_API_TOKEN
Settings → Secrets and variables → Actions → Secrets
| Secret | Source | Description |
|---|---|---|
R2_ACCESS_KEY_ID |
Step 1.2 | R2 API access key |
R2_SECRET_ACCESS_KEY |
Step 1.2 | R2 API secret key |
R2_ACCOUNT_ID |
Step 1.3 | Cloudflare account ID |
CLOUDFLARE_API_TOKEN |
Step 1.4 | API token for Workers/cache |
CLOUDFLARE_ZONE_ID |
Step 1.3 | Zone ID for cache purge |
Settings → Secrets and variables → Actions → Variables
| Variable | Value | Description |
|---|---|---|
R2_BUCKET |
webmail-prod |
R2 bucket name |
name = "webmail-cdn"
main = "src/index.js"
compatibility_date = "2024-01-01"
routes = [
{ pattern = "mail.yourdomain.com/*", zone_name = "yourdomain.com" }
]
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "webmail-prod" # Updated by CI/CDflowchart LR
subgraph Cloudflare Worker - worker/src/index.js
A["SPA routing:<br/>return index.html<br/>for navigation requests"]
B["Cache headers<br/>per asset type"]
C["Security headers<br/>CSP, X-Frame-Options, etc."]
D["Serve assets<br/>from R2 bucket"]
end
Your Domain → DNS → Add record
| Type | Name | Content | Proxy |
|---|---|---|---|
A |
mail |
192.0.2.1 |
Proxied |
Traffic routes through the Worker — the A record is a placeholder.
# .env or CI environment
VITE_WEBMAIL_API_BASE=https://api.forwardemail.netThe following variables are injected automatically by vite.config.js at build
time via the define option (no manual configuration needed):
| Variable | Source | Purpose |
|---|---|---|
VITE_PKG_VERSION |
package.json |
Semver for clear-site-data version gate |
VITE_APP_VERSION |
version + hash |
Full version for cache busting |
VITE_BUILD_HASH |
MD5 of version+ts | Unique per-build fingerprint |
See Technology Stack — Build-Time Environment Variables for details.
None needed — the app is entirely client-side after build.
There are two separate GitHub Actions workflows:
Runs on every push to main and on pull requests. Performs lint, format, unit tests, build, and E2E tests (PRs only). Does not deploy.
Runs only when a GitHub Release is published. Builds the app, syncs to R2, deploys the Worker, and purges the Cloudflare cache.
flowchart TD
subgraph CI ["CI workflow (push / PR)"]
A1["Install"] --> A2["Lint + Format"] --> A3["Unit tests"] --> A4["Build"]
A4 --> A5["E2E tests (PR only)"]
end
subgraph Deploy ["Deploy workflow (release published)"]
B1["Install + Build"] --> B2["Deploy to R2"] --> B3["Deploy Worker"] --> B4["Purge CDN cache"]
end
R["pnpm release → GitHub Release published"] --> Deploy
Releases are managed locally using np:
pnpm releaseThis will:
- Verify a clean working tree and up-to-date
mainbranch - Run lint, format, tests, and build
- Bump the version in
package.jsonand create a git tag - Push the commit and tag to GitHub
- Publish a GitHub Release, which triggers the Deploy workflow
flowchart TD
A["1. pnpm release"] --> B["2. Monitor GitHub Actions (Deploy workflow)"] --> C["3. Verify"]
C --> D["R2 bucket has files?"]
C --> E["Worker deployed?<br/>npx wrangler deployments list"]
C --> F["Site loads?<br/>https://mail.yourdomain.com"]
- App loads at https://mail.yourdomain.com
- Login works
- Can view mailbox
- Can compose and send email
- Can view calendar
- Service worker registers (DevTools → Application)
- Assets cached (check Cache-Control headers)
- Lighthouse score > 90
- No console errors
- HTTPS enforced
- Security headers present
- No mixed content warnings
| Problem | Fix |
|---|---|
| Worker not serving files | cd worker && pnpm tail — Check wrangler.toml routes |
| R2 bucket empty | aws --endpoint-url "$ENDPOINT" s3 ls "s3://${R2_BUCKET}/" |
| Cache not clearing | Manual purge: curl -X POST ".../purge_cache" --data '{"purge_everything":true}' |
| Deploy 403 error | Verify API token has: Workers Scripts: Edit, Workers R2: Edit, Cache Purge: Purge, Workers Routes: Edit |
| Deploy not triggering | Ensure pnpm release published a GitHub Release (check the Releases tab), not just a tag |
flowchart TD
A["1. Create R2 bucket: webmail-staging"] --> B["2. Create Worker: update wrangler.toml name"]
B --> C["3. Add route: staging-mail.yourdomain.com/*"]
C --> D["4. Create GitHub environment with separate secrets"]
D --> E["5. Add environment filter to deploy.yml"]
| Resource | Location |
|---|---|
| R2 Bucket | Cloudflare Dashboard → R2 |
| Worker | Cloudflare Dashboard → Workers & Pages |
| DNS | Cloudflare Dashboard → Your Domain → DNS |
| Secrets | GitHub → Settings → Secrets and variables |
| CI Workflow | .github/workflows/ci.yml |
| Deploy Workflow | .github/workflows/deploy.yml |
| Worker Config | worker/wrangler.toml |