BoxLang 🚀 A New JVM Dynamic Language Learn More...

AppDotEnv

v2.0.0 Modules

AppDotEnv

Appdotenv Logo

A per-application .env for ColdBox apps that share one CFML engine with other sites.

On a typical shared Windows/IIS server, every site runs in the same Adobe ColdFusion (or Lucee) JVM. Loading .env the usual way puts each value into JVM system properties or the OS environment. Then DB_PASSWORD from one site overwrites another's, and every site can read every other site's secrets. ColdBox's getSystemSetting() only looks there, so it can't help.

AppDotEnv keeps each app's values in that app's own application scope, and reads them the way ColdBox does:

  • Your Application.cfc parses the app's .env into application.dotenv before ColdBox boots.
  • application.dotenv.getSystemSetting(), getSystemProperty() and getEnv() take the same arguments as ColdBox's methods of those names, and return what they returned in development. Switching is a find-and-replace.
  • Handlers, models and interceptors inject DotEnv@appdotenv.
  • An authorized ?fwreinit=<password> re-reads the file and looks for it again, so changing a value or moving the file needs no restart.

Where the file lives

Environment File Why
Development<webroot>/.env The same file CommandBox loads for the dev server.
Production<webroot>/../.env One level above the webroot, so the web server can never serve it.

The webroot file wins when both exist. When neither exists the app still boots on defaults, and the startup log names the production path it expected:

C:\websites\example.com\
    .env        <- production secrets
    wwwroot\    <- IIS physical path and the app root

The location is chosen again on every load, so a reinit follows a file that was added, removed or moved between the two places. While both exist the webroot file still wins, so delete the webroot copy when you move to the production layout.

Install

box install appdotenv

ColdBox loads modules after it reads your config, so the module can't load the file itself. A few lines in Application.cfc do that.

1. Application.cfc

public boolean function onApplicationStart(){
    // Before the ColdBox bootstrap: config CFCs read application.dotenv.
    application.dotenv = new modules.appdotenv.models.DotEnv( appRoot = COLDBOX_APP_ROOT_PATH ).load();

    application.cbBootstrap = new coldbox.system.Bootstrap( COLDBOX_CONFIG_FILE, COLDBOX_APP_ROOT_PATH, COLDBOX_APP_KEY, COLDBOX_APP_MAPPING );
    application.cbBootstrap.loadColdbox();
    return true;
}

public boolean function onRequestStart( string targetPage ){
    // The module re-reads .env on every authorized reinit. These lines cover the two cases it
    // can't: an app that started before they were deployed, and a ColdBox that failed to boot
    // or reinit (its controller is gone, so ColdBox boots again on this request). Fix the
    // file, load any page, and the app recovers without a restart.
    var cbKey = len( trim( COLDBOX_APP_KEY ) ) ? COLDBOX_APP_KEY : "cbController";
    if ( !application.keyExists( "dotenv" ) ) {
        application.dotenv = new modules.appdotenv.models.DotEnv( appRoot = COLDBOX_APP_ROOT_PATH ).load();
    } else if ( !application.keyExists( cbKey ) ) {
        application.dotenv.reload();
    }

    application.cbBootstrap.onRequestStart( arguments.targetPage );
    return true;
}

The dotted path assumes the default install location, modules/appdotenv/. If the module loads without application.dotenv, it throws AppDotEnv.NotBooted with the line to add.

Settings in the Application.cfc pseudo-constructor (this.datasources, this.mappings and so on) run before onApplicationStart, so they can't rely on application.dotenv.

2. TestBox: tests/Application.cfc

The test runner is a separate application with its own this.name, so it has its own application scope, and ColdBox's VirtualApp boots ColdBox inside it. Your root Application.cfc never runs there, so without this step every spec fails with AppDotEnv.NotBooted.

This is the onRequestStart from the standard ColdBox app template with the dotenv lines added. It relies on the template's rootPath variable and /root mapping:

public boolean function onRequestStart( targetPage ){
    // Set a high timeout for long running tests
    setting requestTimeout="9999";

    // The test runner is its own application, so it needs its own application.dotenv before
    // ColdBox boots. It reads the app's real .env; ?fwreinit re-reads it.
    if ( !application.keyExists( "dotenv" ) ) {
        application.dotenv = new root.modules.appdotenv.models.DotEnv( appRoot = rootPath ).load();
    } else if ( url.keyExists( "fwreinit" ) ) {
        application.dotenv.reload();
    }

    // New ColdBox Virtual Application Starter
    request.coldBoxVirtualApp = new coldbox.system.testing.VirtualApp( appMapping = "/root" );

    // If hitting the runner or specs, prep our virtual app
    if ( getBaseTemplatePath().replace( expandPath( "/tests" ), "" ).reFindNoCase( "(runner|specs)" ) ) {
        request.coldBoxVirtualApp.startup();
    }

    // Reload for fresh results
    if ( url.keyExists( "fwreinit" ) ) {
        if ( server.keyExists( "lucee" ) ) {
            pagePoolClear();
        }
        request.coldBoxVirtualApp.restart();
    }

    return true;
}

If your tests/Application.cfc looks different, two things matter: create application.dotenv before ColdBox starts, and point appRoot at the app root, not at /tests. The template's onRequestEnd shuts the virtual app down after each request, but that only removes ColdBox's keys, so the instance lasts across test requests. VirtualApp.restart() doesn't announce preReinit, which is why the fwreinit branch reloads the file itself.

3. Read values

// config/ColdBox.cfc, config/modules/*.cfc
reinitPassword : application.dotenv.getSystemSetting( "COLDBOX_REINIT_PASSWORD", "" ),
defaultSecret  : application.dotenv.getSystemSetting( "CMS_SECRET" ), // no default: a missing key stops the boot

// handlers, models, interceptors
property name="dotenv" inject="DotEnv@appdotenv";
var to = dotenv.getSystemSetting( "CONTACT_TO", "[email protected]" );

A key that is in the file with a blank value (CMS_SECRET=) returns "", as ColdBox does. Only a key found nowhere stops the boot.

ColdBox works out ENVIRONMENT from JVM and OS values, not your file. To read it from the file, add this to config/ColdBox.cfc:

function detectEnvironment(){
    return application.dotenv.getSystemSetting( "ENVIRONMENT", "production" );
}

Other modules' ModuleConfig.cfc defaults that call getSystemSetting() use ColdBox's version, so they don't see the file. Set those values in config/modules/<module>.cfc instead.

API

Method Does
init( path = "", appRoot = "" ) path names the file exactly; appRoot resolves it as described above, again on every load().
load() / reload() Read the file. A missing file isn't an error.
getSystemSetting( key, [default] ) File, then Java system property, then OS env, then default.
getSystemProperty( key, [default] ) File, then Java system property, then default.
getEnv( key, [default] ) File, then OS env, then default.
get( key, [default] ) Alias of getSystemSetting().
has( key ) Whether getSystemSetting() would succeed without a default.
keys() Sorted key names from the file. Safe to log.
describe() One log-safe line: key count and path. The module logs it on load.
getPath(), isLoaded(), getLoadedAt() What the last load read (or looked for), and when.
syncWithRequest( [params] ) Deprecated. Reloads on any fwreinit, even with the wrong password. Remove it from onRequestStart; the module reloads on ColdBox's preReinit.

Without a default, the lookups throw SystemSettingNotFound, the type ColdBox uses. The key is in message and the file path only in extendedInfo. ColdBox's default public error page doesn't show extendedInfo, but its BugReport.cfm and Whoops.cfm templates do, so don't use those in production.

A key in the file wins over JVM and OS values even when it's blank, so another site's JVM-global setting can't replace it. Keys missing from the file fall back to the JVM and the OS, as ColdBox's methods do. On a shared server those are shared by every site, so keep every key the app reads in its .env. Values live in the component's variables scope, so dumping the application scope doesn't print them.

File format

The same rules as CommandBox, which loads this file into the dev server: a JSON object is read as JSON (simple values only), and anything else as a Java properties file. Coming from other dotenv tools, watch for these:

  • Quotes are part of the value: KEY="abc" is "abc", quotes included.
  • # and ! start a comment only at the start of a line. KEY=abc #1 is abc #1.
  • A backslash starts an escape (\t, \n, \uXXXX, \\), and a single backslash before anything else is dropped. Write Windows paths as C:\\data or C:/data.
  • =, : or whitespace ends the key, so KEY: value and KEY value both work.
  • A \ at the end of a line continues the value on the next line.
  • export means nothing: export KEY=1 sets a key named export.
  • There is no ${VAR} interpolation.

Two deliberate differences from CommandBox: the file is read as UTF-8 (CommandBox reads it as ISO-8859-1, which garbles non-ASCII characters), and key lookups ignore case, so keys that differ only in case collide. CRLF line endings and a UTF-8 BOM are fine.

Upgrading to 2.0

2.0 makes each value match what ColdBox's getSystemSetting() returned in development. Check your .env files before upgrading:

  • Quoted values keep their quotes. Remove the quotes.
  • An inline # comment becomes part of the value. Move it to its own line.
  • Backslashes are escapes. Double them, or use forward slashes.
  • export KEY=value no longer sets KEY. Remove export.
  • A blank value returns "" instead of falling through to the JVM, the OS or the default.

Then replace onRequestStart's dotenv lines with the ones above (drop syncWithRequest()), add the TestBox lines, and restart the application once. An instance already in the application scope keeps the old version's code until the application restarts.

Adopting it in an app: checklist

  1. box install appdotenv.
  2. Add the Application.cfc lines above, and the ones in tests/Application.cfc.
  3. Replace getSystemSetting(, getSystemProperty( and getEnv( in config/ with application.dotenv.getSystemSetting( and so on. Inject DotEnv@appdotenv wherever code called them at runtime. Add detectEnvironment() if you set ENVIRONMENT in the file.
  4. Keep secrets out of git and off the server's webroot:
    • .gitignore: .env, .env.*, !.env.example
    • Commit .env.example with every key and only harmless values.
    • Deploy excludes (for example DeployBot's .deployignore): /.*
    • IIS web.config: add .env to <hiddenSegments> under requestFiltering.
  5. Keep the production copy on your dev machine as .env.production at the repo root. The .env.* pattern already ignores it. This module never reads that name; it's only the copy you edit and deploy from.
  6. On the server, put the file at <webroot>\..\.env. Remove inherited permissions and grant Read only to the CFML engine's service account and Administrators.
  7. After changing the server file, run ?fwreinit=<your reinit password>.

What the ACL does and doesn't protect

The file is outside the webroot and IIS has no permission to read it, so it can't be served. But every site in a shared engine runs as the same service account, so CFML code in another site on that server could still fileRead() it. Use a separate engine instance or a sandbox when that matters.

Engines

Adobe ColdFusion 2023 and 2025, BoxLang 1 (CFML compatibility), Lucee 6. Tested on ColdBox 8.

License

Apache 2.0.

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[2.0.0] - 2026-10-23

  • Fix release.json to point to the master branch

  • 2.0 makes every value match what ColdBox's getSystemSetting() returned in development. See "Upgrading to 2.0" in the README, check your .env files, and restart the application once after upgrading: an instance already in the application scope keeps the old code until then.

Changed

  • Breaking: the file is parsed the way CommandBox's commandbox-dotenv parses it: as JSON when it holds a JSON object, otherwise with java.util.Properties. Quotes are kept, # starts a comment only at the start of a line, backslashes are escapes, and export has no meaning. The file is still read as UTF-8.
  • Breaking: a key in the file with a blank value returns "", as ColdBox does. It no longer falls through to JVM and OS values or the default.
  • The module re-reads the file on ColdBox's preReinit, which fires only for a reinit that passed the password check and honours a custom reinitKey. The README's onRequestStart code no longer calls syncWithRequest(); it reloads in place when ColdBox isn't running, so a failed boot recovers without a restart.
  • init() no longer reads the disk. getPath() is blank until the first load().
  • The README shows the complete tests/Application.cfc change TestBox needs.

Added

  • getSystemSetting(), getSystemProperty() and getEnv(), with the same arguments as ColdBox's methods of those names, each checking the file first. get() is now an alias of getSystemSetting().
  • JSON .env files, as CommandBox reads them.

Fixed

  • A DotEnv built with appRoot now looks for the file on every load(), so ?fwreinit follows a .env moved between the webroot and the folder above it. It used to keep the path chosen at startup, and a moved file broke every request until the app restarted.
  • A lookup during a reload can no longer throw the wrong error.

Deprecated

  • syncWithRequest(). It still reloads on any fwreinit, even with the wrong password. Remove it from onRequestStart.

Security

  • A blank key in the file no longer picks up another site's JVM-global value.
  • A reinit with the wrong password no longer re-reads the file.
  • SystemSettingNotFound names the key in message and the file path only in extendedInfo. ColdBox's default public error page doesn't show extendedInfo; its BugReport.cfm and Whoops.cfm templates do.

[1.0.0] - 2026-10-15

Added

  • First release, extracted from angrysam-web. DotEnv parses an app's own .env into application.dotenv before ColdBox boots, so config CFCs can read it without the JVM or the OS environment. It reads the webroot .env in development and the one above the webroot in production.
  • syncWithRequest() reloads the file on a ColdBox reinit, including a reinit sent as a POST field.
  • The module registers that instance as DotEnv@appdotenv and logs where it was loaded from, key names and counts only.

$ box install appdotenv

No collaborators yet.
     
  • {{ getFullDate("2026-10-15T15:47:59-05:00") }}
  • {{ getFullDate("2026-10-23T20:27:43-05:00") }}
  • 77
  • 25