Brownfield & greenfield resource adoption
How stacks adopt existing Cloudflare resources into Tamer state without putting account IDs in config.
North star
| Principle | Meaning |
|---|---|
| No CF IDs in config | UUIDs and bucket names live in state only, filled by sync / apply |
| Name-based matching | Tamer derives expected names from config; sync lists the account API and adopts matches |
| Brownfield = naming | Legacy resources keep their Cloudflare names; reproduce those patterns in naming (functions, not IDs) |
tamer import --cf-id | Escape hatch for one-off gaps — not the primary migration path |
Some resources cannot be renamed on Cloudflare (D1, R2, workflows). Adoption must align logical names + naming hooks with what already exists.
Greenfield vs brownfield
| Greenfield | Brownfield | |
|---|---|---|
| CF names | Tamer defaults (db_{logical}_{env}, r2-{logical}-{env}, …) | Legacy names via naming block |
| Config | stack, workers, resources only | Same + naming: { d1Single, d1Shard, r2Bucket, workerName, workflow, … } |
| First-time flow | bootstrap → sync → plan → apply → … | Same — naming must match CF before sync adopts |
| IDs in config | Never | Never |
Standard command sequence
From the stack repo root (after CLOUDFLARE_* creds and tamer/project.config.ts):
tamer doctor --env dev
tamer bootstrap # once per account
tamer sync --env dev # discover existing resources into state
tamer plan --env dev # preview creates/updates vs config
tamer apply --env dev
tamer migrate --env dev # if D1 declared
tamer deploy --env devUse tamer drift --env dev when state and Cloudflare disagree. Re-run sync after out-of-band changes.
Worker secrets: Tamer cannot import existing CF secret values — seed the vault from .dev.vars / 1Password and push; see secrets.md § Adoption paths.
Multi-stack: bootstrap once per account; apply producers before consumers so ${tamer:import:…} resolves (Quickstart § Cross-stack).
naming block (supported today)
Declare on the root defineConfig({ … }) document. API details (defaults, date parameter, sync matching): JSDoc on NamingConventions in @dragonmastery/tamer (hover in your IDE; source in src/types.ts in this repo).
| Hook | Used for | Default when omitted |
|---|---|---|
d1Single | d1 resources with type: "single" | db_{logical}_{env} |
d1Shard | d1 resources with type: "sharded" | db_{logical}_{YYYYMMDD}_{env} |
r2Bucket | R2 buckets | r2-{logical}-{env} (date arg is YYYYMMDD at apply time; ignore if unused) |
workerName | Deployed Worker script names | {stack}-{workerKey}-{env} (or scriptName override) |
workflow | Workflow registration names | wf-{logical}-{env} (lowercase) |
Each hook receives the stack identity (the stack string) as a parameter so custom formulas can embed it.
Not configurable via naming today: KV, queues, hyperdrive, vectorize, pipelines, AI gateway, secrets store, dispatch namespaces, DNS. Those use built-in derived names.
Reference fixture: fixtures/platform/tamer/project.config.ts (d1Single + r2Bucket for legacy-style names).
Config vs state
- Config: logical names, resource shapes, and naming functions (how to compute CF names).
- State (
tamer-state):cfId/ UUIDs, route ids, workflow ids, etc., aftersyncorapply. - Wrangler output: bindings use state; generated config must not embed raw Cloudflare IDs.
Minimal brownfield example
Existing account: D1 single acme-app-dev, sharded history DBs acme-history-20250115-dev, R2 acme-assets-dev, worker script acme-api-dev, workflow acme-ingest-dev.
import { defineConfig } from "@dragonmastery/tamer";
export default defineConfig({
stack: "acme",
compatibility_date: "2025-12-01",
naming: {
d1Single: (logical, _stack, env) => `acme-${logical}-${env}`,
d1Shard: (logical, date, _stack, env) => {
const d = date.replace(/-/g, "");
return `acme-${logical}-${d}-${env}`;
},
r2Bucket: (logical, _date, _stack, env) => `acme-${logical}-${env}`,
workerName: (stack, workerKey, env) => `${stack}-${workerKey}-${env}`,
workflow: (logical, _stack, env) => `acme-${logical}-${env}`,
},
workers: {
api: {
main: "workers/api/src/index.ts",
resources: {
d1: [
{ logicalName: "app", type: "single" },
{ logicalName: "history", type: "sharded" },
],
r2: [{ logicalName: "assets" }],
workflows: [{ logicalName: "ingest", className: "IngestWorkflow" }],
},
},
},
});Then: bootstrap → sync → plan → apply → deploy.
How sync matches resources
| Resource | Match strategy |
|---|---|
| D1 single | Exact derived name from d1Single |
| D1 sharded | Default: prefix/suffix regex on db_{logical}_…_{env}. Custom d1Shard: derive-and-match — extract shard date from the CF name (YYYYMMDD or YYYY_MM_DD), re-derive via your hook, adopt on equality |
| R2 | Exact derived name (custom r2Bucket uses today's date, same as apply) |
| Workflows | Exact derived name from workflow hook (or default wf-…) |
sync, drift, plan, apply, and import all use the same NamingEngine derivation — configure hooks once.
Workflows
Workflow names are immutable on Cloudflare. For brownfield adoption, add a naming.workflow function that returns the live registration name:
naming: {
workflow: (logical, stack, env) => {
if (env === "dev") return `${logical}-workflow-${env}`;
return `${stack}-app-saai-${logical}-workflow-${env}`;
},
},Declare workflows under resources.workflows with matching logical names. tamer sync --env dev adopts workflows whose CF name equals the derived name.
If a hook is omitted, the default is wf-{logical}-{env} (lowercase). Use tamer import --target workflow:<name> --cf-id <id> only when sync cannot match (e.g. one-off typo in a legacy name you cannot encode in a function).
Escape hatch: tamer import
tamer import --env dev --target d1:app --cf-id <d1-uuid>
tamer import --env dev --target d1:history --shard-date 2025-01-15 --cf-id <d1-uuid>
tamer import --env dev --target r2:assets --cf-id <bucket-name>
tamer import --env dev --target workflow:ingest --cf-id <workflow-id>Verifies the Cloudflare object exists and that its name matches the name your naming hooks (or defaults) produce. Does not rename resources. Prefer sync for bulk adoption when names align.
Gaps & limitations (honest)
| Area | Today |
|---|---|
| KV / queue / hyperdrive / … | Per-resource cloudflareName overrides (stack naming.* hooks optional future) |
R2 sync with custom r2Bucket | Exact name match only (today's date used when calling the hook, same as apply) |
Tenant runtime D1 (wfp tenant provision) | Separate naming (db_{role}_{product}_{workspace}_{env}) — not covered by stack naming hooks |
| Shard dates in exotic formats | extractD1ShardDate recognizes _{YYYYMMDD}_ and _{YYYY_MM_DD}_; other date encodings need import per shard |
Per-resource cloudflareName
When legacy Cloudflare names differ per logical resource (not one formula for the whole stack), set optional cloudflareName on each managed resource config:
workflows: [
{
logicalName: "referral-event",
className: "ReferralEventWorkflow",
cloudflareName: (stack, env) =>
env === "dev"
? "referral-event-workflow-dev"
: `${stack}-app-saai-referral-event-workflow-${env}`,
},
],Resolution order: resource.cloudflareName → stack naming.{kindHook} → {@link NamingEngine} default. logicalName stays stable for state keys and ${tamer:…} refs; IDs remain in state after sync / apply.
Use stack-level naming.* for shared formulas (e.g. all D1 shards); use cloudflareName when dev/prod names differ per workflow or shard prefix (db_ vs dbh_). External D1 keeps databaseName; workers use scriptName.
Related docs
- Quickstart — install, layout, bootstrap flow
- Secrets operator guide —
tamer secretsvault, master key, push/deploy - Naming Conventions — default patterns + override layers
- README → Import — all
--targetkinds and flags - README → Resource kinds — declare resources in config