Example: SPA with API
A complete multi-worker stack with a Vue/Vite SPA and a backend API worker. This is the pattern used in production by DragonMastery apps. Includes ephemeral PR preview environments.
Project structure
my-app/
tamer/
project.config.ts
workers/
api/
base.ts
env/
dev.ts
prod.ts
spa/
base.ts
env/
dev.ts
prod.ts
workers/cf-app-api/ # API worker code
src/worker.ts
wrangler.json # generated by tamer (gitignored)
apps/cf-app-spa/ # SPA app code
src/
vite.config.ts
dist/ # built by tamer deploy (gitignored)
wrangler.json # generated by tamer (gitignored)
package.json
.env # CF credentials only (gitignored)Project config
// tamer/project.config.ts
import { defineConfig } from "@dragonmastery/tamer";
import { apiWorker } from "./workers/api/base";
import { spaWorker } from "./workers/spa/base";
export default defineConfig({
stack: "my-app",
account_id: "abc123",
compatibility_date: "2025-05-19",
workers: {
api: apiWorker,
spa: spaWorker,
},
outputs: {
api_worker_name: "${tamer:worker:api.name}",
},
});Ephemeral PR-preview envs
This example deploys ephemeral pr-<n> envs (see "PR preview" below). For a stack that uses ephemeral envs but has no Workers-for-Platforms tenants, declare wfp with just the pattern — namespaces is optional:
wfp: {
ephemeralEnvPattern: "^pr-",
},This enables ephemeral-env semantics (dispatch-namespace sharing, env.dev override fallback, tamer env gc eligibility) without provisioning tenant workers. See Environments.
API worker
Routes are declared once in the base config with the bare apex. Tamer's route expansion handles the {env}. prefix automatically — dev gets dev.api.myapp.com, pr-42 gets pr-42.api.myapp.com, prod gets the bare apex. No per-env route overrides needed.
// tamer/workers/api/base.ts
import { defineWorker } from "@dragonmastery/tamer";
import { apiDevEnv } from "./env/dev";
import { apiProdEnv } from "./env/prod";
export const apiWorker = defineWorker({
path: "workers/cf-app-api",
scriptName: "my-app-api",
main: "src/worker.ts",
resources: {
d1: [{ logicalName: "settings", type: "single", binding: "DB" }],
r2: [{ logicalName: "uploads", binding: "R2" }],
},
secrets: {
required: ["STRIPE_API_KEY", "JWT_SECRET"],
},
vars: {
ENVIRONMENT: "local",
WEBSITE_URL: "http://localhost:5993",
},
// One route declaration — expansion handles per-env hostnames:
// dev → dev.api.myapp.com
// pr-42 → pr-42.api.myapp.com
// prod → api.myapp.com (bare apex)
tamerRoutes: [
{ host: "api.myapp.com", customDomain: true },
],
env: {
dev: apiDevEnv,
prod: apiProdEnv,
},
});// tamer/workers/api/env/dev.ts
import type { EnvOverride } from "@dragonmastery/tamer";
// ${tamer:env} resolves to the current env name — "dev" for dev deploys,
// "pr-42" for PR previews (ephemeral envs fall back to dev overrides).
export const apiDevEnv = {
vars: {
ENVIRONMENT: "${tamer:env}",
WEBSITE_URL: "https://${tamer:env}.myapp.com",
},
} satisfies EnvOverride;// tamer/workers/api/env/prod.ts
import type { EnvOverride } from "@dragonmastery/tamer";
export const apiProdEnv = {
vars: {
ENVIRONMENT: "prod",
WEBSITE_URL: "https://myapp.com",
},
} satisfies EnvOverride;SPA worker
The SPA worker serves static assets and declares a build step so Tamer compiles the bundle per env with the correct API URL baked in.
// tamer/workers/spa/base.ts
import { defineWorker } from "@dragonmastery/tamer";
import { spaDevEnv } from "./env/dev";
import { spaProdEnv } from "./env/prod";
export const spaWorker = defineWorker({
path: "apps/cf-app-spa",
scriptName: "my-app-web",
assets: {
directory: "dist",
not_found_handling: "single-page-application",
},
build: { command: "vite build" },
vars: {
ENVIRONMENT: "local",
VITE_API_CLIENT_URL: "http://127.0.0.1:8993/v1",
VITE_APP_ENV: "local",
},
// Same expansion as the API worker:
// dev → dev.myapp.com
// pr-42 → pr-42.myapp.com
// prod → myapp.com
tamerRoutes: [
{ host: "myapp.com", customDomain: true },
],
env: {
dev: spaDevEnv,
prod: spaProdEnv,
},
});// tamer/workers/spa/env/dev.ts
import type { EnvOverride } from "@dragonmastery/tamer";
export const spaDevEnv = {
vars: {
ENVIRONMENT: "${tamer:env}",
// ${tamer:env} makes this work for dev AND ephemeral PR envs:
// dev → https://dev.api.myapp.com/v1
// pr-42 → https://pr-42.api.myapp.com/v1
VITE_API_CLIENT_URL: "https://${tamer:env}.api.myapp.com/v1",
VITE_APP_ENV: "${tamer:env}",
},
} satisfies EnvOverride;// tamer/workers/spa/env/prod.ts
import type { EnvOverride } from "@dragonmastery/tamer";
export const spaProdEnv = {
vars: {
ENVIRONMENT: "prod",
VITE_API_CLIENT_URL: "https://api.myapp.com/v1",
VITE_APP_ENV: "prod",
},
} satisfies EnvOverride;How ${tamer:env} + ephemeral fallback works
| Env | VITE_API_CLIENT_URL resolves to | How |
|---|---|---|
local | http://127.0.0.1:8993/v1 | Base config (local override) |
dev | https://dev.api.myapp.com/v1 | Dev override: ${tamer:env} → dev |
pr-42 | https://pr-42.api.myapp.com/v1 | Ephemeral fallback to dev, ${tamer:env} → pr-42 |
prod | https://api.myapp.com/v1 | Prod override (bare apex) |
No per-PR config entries. The dev override block serves double duty for all non-prod, non-local envs.
Deploy workflow
Dev / prod (CI)
tamer bootstrap # once per account
tamer apply --env dev # provision D1, R2, KV
tamer deploy --env dev # build SPA + deploy both workers + routesPR preview (ephemeral)
# No bootstrap needed — account is already bootstrapped.
# No per-PR config — ephemeral fallback + ${tamer:env} handles it.
# Copy dev's vault contents into the pr-42 namespace (ciphertext-only copy;
# no decryption, so dev's master key is required but pr-42's is not):
tamer secrets copy --from dev --to pr-42
tamer apply --env pr-42 # creates db_settings_pr-42, etc.
tamer deploy --env pr-42 # builds SPA with pr-42 API URL, deploys
# → live at pr-42.myapp.com
# → API at pr-42.api.myapp.com
# Cleanup when PR closes:
tamer destroy --env pr-42 --confirm-env pr-42 --wipe-metadata --forceHow deploy works per worker
Deploy runs per worker in topological order (API first if SPA has a service binding; otherwise order doesn't matter):
API worker (no build step):
- Push secrets from vault (
STRIPE_API_KEY,JWT_SECRET) - Generate
wrangler.json wrangler types→wrangler deploy
SPA worker (has build step):
- Generate
wrangler.json(with resolved vars, WITHOUTbuildfield) - Spawn
vite buildinapps/cf-app-spa/with resolved vars as env:VITE_API_CLIENT_URL=https://dev.api.myapp.com/v1→ baked into bundle
wrangler types→wrangler deploy(uploads the pre-builtdist/)
What does NOT happen
- No
.envfile written toapps/cf-app-spa/ - No
wrangler.jsonread-back by a bridge script - No Wrangler custom build (the
buildfield is stripped fromwrangler.json) - No runtime config endpoint
- No per-PR bootstrap or config entries
.env file
# .env (repo root, gitignored)
CLOUDFLARE_ACCOUNT_ID=abc123
CLOUDFLARE_API_TOKEN=your-token
TAMER_SECRETS_KEY_dev=your-vault-keyJust Tamer's auth credentials. No VITE_* values, no app config.