Deployment
Production configuration
This page shows you what to set, what to override, and what to verify before a Wheels app takes real traffic. You’ll configure config/environment.cfm and config/production/settings.cfm, wire secrets through .env and the process environment, and walk a pre-boot checklist that catches the failures most teams only notice at 3am.
You’ll learn:
- How to flip an app into production mode and what that changes
- Which settings are production-critical and what to override
- How Wheels reads environment variables —
.env, then the process environment - A ten-point checklist to run before first production boot
Flip the environment switch
Section titled “Flip the environment switch”config/environment.cfm selects the active environment. The scaffold ships with development hardcoded:
<cfscript> set(environment="development");</cfscript>In production, read WHEELS_ENV from the process instead so the same artifact can boot dev, staging, and prod:
<cfscript> set(environment=env("WHEELS_ENV", "production"));</cfscript>Wheels boots, calls config/environment.cfm, and then loads config/settings.cfm followed by config/<environment>/settings.cfm. Anything you set() in the per-environment file wins (vendor/wheels/events/onapplicationstart.cfc:325-328).
What switching to production changes, automatically:
showErrorInformation→false— error pages stop leaking stack traces (vendor/wheels/events/init/debugging.cfm:22-25).sendEmailOnError→true— seterrorEmailToAddressor you’ll get nothing.showDebugInformation→false— the floating debug panel is hidden (debugging.cfm:26-28).cacheActions,cacheImages,cachePages,cachePartials,cacheQueries→ alltrue(vendor/wheels/events/init/caching.cfm:15-21).redirectAfterReload→true— the reload URL 302s after success, strippingreloadandpasswordfrom the query string. Defaults tofalse(vendor/wheels/events/init/orm.cfm:26) and flips totruefor bothproductionandmaintenance(orm.cfm:52-54).- URL-based environment switching is disabled unless you explicitly re-enable it (
onapplicationstart.cfc:360-369, resolved by$resolveAllowEnvironmentSwitchViaUrl()at:516-524— disabled by default inproduction,testing, andmaintenance). - Migrations refuse to run
down(onapplicationstart.cfc:300-304inverts only for development).
You do not set any of those manually. Setting environment to production is the switch.
Configure config/production/settings.cfm
Section titled “Configure config/production/settings.cfm”Create config/production/settings.cfm. This file is read only when the active environment is production:
<cfscript> // Real datasource — never H2 or SQLite in production. set(dataSourceName=env("WHEELS_DATASOURCE", "myapp_prod")); set(dataSourceUserName=env("WHEELS_DB_USER", "")); set(dataSourcePassword=env("WHEELS_DB_PASSWORD", ""));
// Rotate this per-deploy. An empty value disables ?reload= entirely — // see the pre-boot checklist, item 2. set(reloadPassword=env("WHEELS_RELOAD_PASSWORD", ""));
// Persistent CSRF encryption key — required when csrfStore="cookie". set(csrfCookieEncryptionSecretKey=env("CSRF_KEY", ""));
// Error notifications. set(errorEmailToAddress=env("ERROR_EMAIL", "ops@example.com")); set(errorEmailFromAddress="noreply@example.com");
// URL rewriting: "On" when your web server rewrites /foo → /index.cfm/foo. set(URLRewriting="On");</cfscript>Nothing in that block is invented. Each key is defined in vendor/wheels/events/init/:
| Setting | Default | Source |
|---|---|---|
dataSourceName | app folder name (lowercased) | onapplicationstart.cfc:254-261 |
dataSourceUserName, dataSourcePassword | "" | events/init/orm.cfm:2-3 |
reloadPassword | "" | events/init/orm.cfm:25 |
csrfCookieEncryptionSecretKey | "" | events/init/security.cfm:30 |
errorEmailToAddress, errorEmailFromAddress | "" | events/init/debugging.cfm:8-9 |
URLRewriting | detected from CGI | onapplicationstart.cfc:245-252 |
subpath | "" | onapplicationstart.cfc:307-342 |
How Wheels reads environment variables
Section titled “How Wheels reads environment variables”env("NAME", "default") is the canonical accessor. It checks application.env first, then server.system.environment, then returns the default (vendor/wheels/Global.cfc:597-616).
application.env is populated by the scaffold’s Application.cfc at onApplicationStart():
- Reads
.envat the app root (cli/lucli/templates/app/public/Application.cfc:52-56). - Resolves
WHEELS_ENVfrom.envfirst, thenjava.lang.System.getenv("WHEELS_ENV")(Application.cfc:58-73). - Reads
.env.<environment>on top if present — so.env.productionoverrides.env(Application.cfc:75-81). - Interpolates
${VAR}references between entries (Application.cfc:84). - Copies the merged struct to
application.envinsideonApplicationStart()(Application.cfc:103).
The practical rule: commit nothing secret. Ship .env.example with empty values, inject real values at deploy time. A deploy script, systemd EnvironmentFile=, Dockerfile ENV, or Kubernetes secret-mount all work.
WHEELS_ENV=productionWHEELS_DATASOURCE=myapp_prodWHEELS_DB_USER=myappWHEELS_DB_PASSWORD=xxxxxxxxxxWHEELS_RELOAD_PASSWORD=rotate-me-per-deployCSRF_KEY=base64-aes-key-from-secrets-managerERROR_EMAIL=ops@example.comSwitching between dev, staging, and prod
Section titled “Switching between dev, staging, and prod”Three mechanisms, in order from safest to most dangerous:
Per-environment artifacts (recommended)
Section titled “Per-environment artifacts (recommended)”Each deploy target exports WHEELS_ENV before the CFML server boots. config/environment.cfm reads it, the right per-env settings file loads, and that’s it. Restart required to change environments.
URL-based reload (development only)
Section titled “URL-based reload (development only)”?reload=true&password=<reloadPassword> re-runs onApplicationStart. ?reload=production&password=<reloadPassword> switches the running app into production (onapplicationstart.cfc:180-204). Switching to the environment that’s already active is a no-op.
Wheels disables URL-based switching automatically in production, testing, and maintenance environments unless you explicitly set(allowEnvironmentSwitchViaUrl=true) in your per-env settings (onapplicationstart.cfc:360-369; $resolveAllowEnvironmentSwitchViaUrl() at :516-524). Leave it disabled. A cached reload password plus URL switching plus production is a bad combination.
The reload password uses a constant-time comparison ($secureCompare() in vendor/wheels/Global.cfc, called from both onapplicationstart.cfc and public/Application.cfc) and rate-limits failed attempts per client IP — 5 failures in 5 minutes — on both the cold-start and warm-application paths, logging each rejected or accepted attempt to wheels_security.log (#3062). Rotate it on every production deploy anyway — it’s trivial to rotate and the value ends up in nginx logs if someone misconfigures TLS termination.
The maintenance environment
Section titled “The maintenance environment”set(environment="maintenance") puts Wheels into planned-downtime mode — EventMethods.cfc:236-258 responds with HTTP 503 and renders app/events/onmaintenance.cfm instead of running the normal request lifecycle, unless the client IP is listed in ipExceptions. Use it for long migrations or config rollouts.
Pre-boot checklist
Section titled “Pre-boot checklist”Run through this before the first production request. Each item maps to a framework check — if you skip it, Wheels won’t always fail loudly.
environmentisproduction. Verify with?controller=wheels&action=info(dev only) or by checkingapplication.wheels.environmentin a smoke-test action. If it saysdevelopment,showErrorInformationis stilltrueand caching is off.reloadPasswordis set and rotated. An empty password disables?reload=entirely — both URL-based environment switching and plain?reload=truerequire a non-empty configured password plus a matchingpasswordparameter (the warm-application gate inpublic/Application.cfcfails closed since #3062, mirroring the cold-start leg inonapplicationstart.cfc). Wheels writes awheels_securitywarning log on boot if it’s blank. Set it to a random value unique to this deploy so operators can still reload over HTTP; leave it blank only if you never want URL reload to work.showErrorInformationisfalse. Theproductionswitch flips this, but a strayset(showErrorInformation=true)inconfig/settings.cfmwill leak stack traces. Grep for it.dataSourceNamepoints at a real database. Not H2, notwheelstestdb, not a shared staging instance. Confirm with a read-only action that callsmodel("User").count()or similar.csrfCookieEncryptionSecretKeyis set ifcsrfStore="cookie".csrfStoredefaults to"session"unconditionally (events/init/security.cfm:3) — cookie storage is always an explicit opt-in. WithcsrfStore="cookie"and no key, the first CSRF check throwsWheels.Security.MissingCsrfKey(controller/csrf.cfc:148-157).- Session storage matches the node topology.
sessionflash storage (the default when session management is on —events/init/orm.cfm:57-64; cookie attributes at:69-73) requires sticky sessions or a session-replication setup behind the load balancer. With multiple nodes and no stickiness, useflashStorage="cookie"explicitly. - Error email is wired.
sendEmailOnErroris auto-enabled in production (debugging.cfm:22-25). IferrorEmailToAddressis empty, Wheels still tries to send —events/EventMethods.cfc:5-13reads the address at error time — and the mail bounces silently. Set it. - URL rewriting matches the web server.
set(URLRewriting="On")only works if nginx/Apache/Tuckey rewrites/posts/1to/index.cfm/posts/1. If rewrites aren’t configured, leave it at"Partial"or"Off", or every link breaks. allowEnvironmentSwitchViaUrlis not re-enabled. Confirm by greppingconfig/production/settings.cfm— don’t set it totrue. If you need to toggle environments, redeploy.- Caches are warmed before traffic cutover. The very first request to a freshly started node is dominated by one-time CFML-to-bytecode compilation — profiling a stock app puts roughly 85% of a ~1.2s cold start in the engine’s compiler, versus sub-millisecond once warm. That cost is paid once per process, so the goal is to move it off the first real visitor and onto the deploy sequence:
- Apps scaffolded with
wheels newship a/uproute (app/controllers/Up.cfc).wheels deploy’s proxy healthcheck probes/upbefore flipping traffic, so the dispatch → controller → render path is compiled on the new node before it serves anyone. Touch your hottest models in that action (e.g.model("Post").count()) to warm their ORM column metadata too — the first query against a model is the other notable one-time cost. - Not using
wheels deploy? Curl your latency-sensitive endpoints from the deploy script before enabling the node at the load balancer. A single warm-up request per controller is usually enough, sincecacheControllerConfig,cacheModelConfig,cacheDatabaseSchema, andcacheFileCheckingare always-on in every environment (events/init/caching.cfm:3-7).
- Apps scaffolded with
- Template inspection is set to
never(engine-level). In development the CFML engine re-checks each template’s source timestamp on every request so edits are picked up live; in production that filesystem stat is pure per-request overhead. Set the engine’s template-inspection mode toneverso it trusts the compiled-class cache and skips the check — Lucee: admin → Performance/Caching → “Inspect Templates” =Never(orinspectTemplate="never"in.CFConfig.json); Adobe ColdFusion: enable “Trusted Cache”. This is engine configuration, not a Wheels setting, so it isn’t flipped byenvironment="production". Pair it with a warm-up (item 10) and a cache-clear/reload step in your deploy so a new release’s templates still recompile once.
What production does not change
Section titled “What production does not change”A few things you might expect to flip, that don’t:
- Routes are not cached.
config/routes.cfmis re-evaluated on every reload. There’s no “compiled routes” mode — route dispatch is cheap enough that it doesn’t matter. - Migrations are not auto-run.
autoMigrateDatabaseisfalsein every environment (onapplicationstart.cfc:282; the auto-run gate is at:461-463). Runwheels migrate latestfrom your deploy script explicitly. .envfiles are not required. If you don’t ship one,application.envstays empty andenv()falls through toserver.system.environment. That’s fine — it’s why systemdEnvironmentFile=and DockerENVboth work out of the box.