Load AWS SSM Parameter Store secrets into a .env file

How do you get AWS SSM Parameter Store parameters into a local .env file without a hand-written script? List each variable and its parameter path once in envilder.json, then run envilder: it reads every parameter, decrypts SecureString values and writes a .env file that parses back to the same values.

Prerequisites

  • Node.js 22.12 or later. Run the CLI with npx envilder, or install it with npm install -g envilder.
  • AWS credentials the SDK can find: a named profile, environment variables, or an SSO session started with aws sso login --profile <name>.
  • Read access to every parameter you map. The CLI calls GetParameter once per variable, with decryption on.

The minimum IAM policy allows ssm:GetParameter on the parameter ARNs in your map file. A wildcard on the path prefix covers one application and environment:

policy.jsonjson
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "ssm:GetParameter",
      "Resource": "arn:aws:ssm:eu-west-1:123456789012:parameter/my-app/dev/*"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:eu-west-1:123456789012:key/<key-id>"
    }
  ]
}

The second statement is only needed for parameters encrypted with a customer-managed KMS key; replace <key-id> with that key's ID.

The push commands in the example below need one more permission, ssm:PutParameter, on the same ARNs. Push uses the default KMS key of the account, so no kms:Encrypt statement is needed. Grant it only to whoever creates parameters: pulling needs just the read policy above.

Which region is used

Envilder picks the region in this order and prints it on the first line of every run (region=eu-west-1 in the output below):

  1. The AWS_REGION environment variable.
  2. The AWS_DEFAULT_REGION environment variable.
  3. The region the AWS SDK finds for the selected profile (--profile, then $config.profile, then the default), for example region in ~/.aws/config.
  4. us-east-1, when none of the above is set.

Parameters stored in another region are reported as not found, so check this line first when a parameter you know exists is skipped.

Example: from SSM parameters to a .env file

We start from nothing and create three parameters under /my-app/dev/, each read into one variable:

SSM parameterVariable in .env
/my-app/dev/db-passwordDB_PASSWORD
/my-app/dev/api-tokenAPI_TOKEN
/my-app/dev/database-urlDATABASE_URL
⚠️
Push always writes the value (PutParameter with overwrite), so an existing parameter gets a new version. --value stays in your shell history: use it for test values, and push real secrets from a .env file with envilder --push.

If the parameters already exist, do not push them again: write envilder.json by hand with the same shape, one variable name per parameter name. If a variable already maps to another path, push exits with 1 and pushes nothing; --no-map-update skips the map file. Push mode reference: every option and the map file rules

Push each value once. A single-secret push stores the value as a SecureString parameter and, in the same step, creates envilder.json or adds the entry to it (CLI 0.16 or later). The first command creates the file:

envilder --push --key=DB_PASSWORD --value=local-dev-password \
  --secret-path=/my-app/dev/db-password
envilder --push --key=API_TOKEN --value=dev-token-7f3a9c21 \
  --secret-path=/my-app/dev/api-token
envilder --push --key=DATABASE_URL \
  --value='postgres://app:p@ss w0rd#1@db.internal:5432/app' \
  --secret-path=/my-app/dev/database-url

Result lines of each command (the header and identity lines are left out). Values and paths are masked:

⭐ SECRET PUSHED  —  DB_PASSWORD=***************ord → ********************ord
✓ Created envilder.json

⭐ SECRET PUSHED  —  API_TOKEN=***************c21 → ******************ken
✓ Added API_TOKEN to envilder.json

⭐ SECRET PUSHED  —  DATABASE_URL=********************************************app → *********************url
✓ Added DATABASE_URL to envilder.json

envilder.json now maps the three variables. It holds names and paths only, never values:

envilder.jsonjson
{
  "DB_PASSWORD": "/my-app/dev/db-password",
  "API_TOKEN": "/my-app/dev/api-token",
  "DATABASE_URL": "/my-app/dev/database-url"
}

Run envilder with no options. Pull is the default mode: it reads envilder.json and writes .env in the current directory.

envilder
☁ AWS identity · account=123456789012 · region=eu-west-1 · profile=default

🪙  RESOLVING SECRETS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ✓ DB_PASSWORD         → ***************ord
  ✓ API_TOKEN           → ***************c21
  ✓ DATABASE_URL        → ********************************************app

⭐ LEVEL CLEARED  —  3/3 secrets loaded · .env written

Values are masked: for a value longer than 10 characters only the last three characters are shown, and paths are masked the same way.

The resulting .env. Envilder quotes DATABASE_URL because it contains a space and a #, and reads the result back the way dotenv does before writing:

.envdotenv
DB_PASSWORD=local-dev-password
API_TOKEN=dev-token-7f3a9c21
DATABASE_URL='postgres://app:p@ss w0rd#1@db.internal:5432/app'

Running it again changes nothing: a line that already holds the right value is not rewritten, and lines the map file does not declare are kept.

⚠️
The .env file holds the real values. Add .env and .env.prod (used below) to .gitignore: the pattern .env does not match .env.prod. envilder.json holds only names and paths, so it is safe to commit.

Several environments

Keep one map file per environment and choose it with --map. The file names are yours. This envilder.prod.json maps the same variables to the /my-app/prod/ parameters:

envilder.prod.jsonjson
{
  "DB_PASSWORD": "/my-app/prod/db-password",
  "API_TOKEN": "/my-app/prod/api-token",
  "DATABASE_URL": "/my-app/prod/database-url"
}

Pass --envfile for the file to write and --profile for the AWS profile that can read those parameters:

envilder --map=envilder.prod.json --envfile=.env.prod --profile=prod-account
☁ AWS identity · account=123456789012 · region=eu-west-1 · profile=prod-account

🪙  RESOLVING SECRETS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  ✓ DB_PASSWORD         → *******************4aa
  ✓ API_TOKEN           → ****************0e3
  ✓ DATABASE_URL        → ***********************************app

⭐ LEVEL CLEARED  —  3/3 secrets loaded · .env.prod written

--profile overrides $config.profile in the map file, and AWS_REGION still wins over the region of the profile.

Or skip the file with envilder exec

If you only need the values to start a process, envilder exec resolves the same map file and starts your command with the parameters added to its environment, writing nothing to disk:

envilder exec -- node app.js
envilder: 3 secrets resolved (aws) → node …

The summary goes to stderr, so it never mixes with the output of your command. The command reads the values as ordinary environment variables.

Which one to use

  • Use envilder exec when you start the process yourself: npm scripts, tests, migrations. No file with secrets stays on disk, and a missing or unreadable parameter stops the command before it starts (exit code 125).
  • Use a .env file when a tool reads the file by path or you cannot wrap its start command: Docker Compose env_file, an IDE run configuration, a framework that loads .env itself. A missing parameter is only a warning in this mode, so check the summary (2/3 instead of 3/3).

Compared with a script

The usual alternative is a shell script around get-parameters-by-path. For a quick export it works:

aws ssm get-parameters-by-path --path /my-app/dev/ --recursive \
  --with-decryption --query 'Parameters[].[Name,Value]' --output text \
  | while IFS=$'\t' read -r name value; do
      echo "$(basename "$name" | tr 'a-z-' 'A-Z_')=$value"
    done > .env

Where it falls short once the file is shared by a team:

  • --with-decryption is easy to forget. Without it a SecureString comes back encrypted and the script writes the ciphertext.
  • The API returns at most 10 parameters per page. The AWS CLI follows the pages for you, but a script built on an SDK or a raw HTTP call has to follow NextToken itself.
  • Values are written unquoted: DATABASE_URL=postgres://app:p@ss w0rd#1@db.internal:5432/app is read back by dotenv as postgres://app:p@ss w0rd, because # starts a comment.
  • > .env replaces the whole file, so a line such as LOG_LEVEL=debug is lost, and variable names come from the last path segment, so renaming a parameter silently renames the variable.
  • Nothing in Git says which parameters the application needs. With envilder.json the contract is reviewed in a pull request, and a variable without a parameter shows up as a warning.

The script needs no Node.js, makes one call per page of results instead of one per variable, and exports everything under a path without a map. Envilder reads only the names in the map file, one GetParameter call each, so it needs ssm:GetParameter, not ssm:GetParametersByPath.

Common errors

MessageFix
Could not load credentials from any providersThe AWS SDK found no credentials. Run aws sso login --profile <name>, export credentials, or pass --profile for a named profile. Exit code 1, no .env written.
User: arn:aws:sts::123456789012:assumed-role/dev/session is not authorized to perform: ssm:GetParameter on resource: arn:aws:ssm:eu-west-1:123456789012:parameter/my-app/dev/api-token …The credentials work but cannot read that parameter. Allow ssm:GetParameter on its ARN, plus kms:Decrypt for a customer-managed key. The .env file is left untouched.
secret not found (path: *******************dsn) — skippedThe parameter does not exist in the region on the first line of the output. Check the path and the region. The exit code is still 0, so read the 1/2 in the summary.
Invalid environment variable name "MY VAR": names may only contain letters, digits, underscore, dot, hyphen and slash, so that they can be read back from a .env fileRename the key in envilder.json. The run stops before any parameter is read.
No map file found. Provide --map or create envilder.json in the current directory.Run envilder from the directory that holds envilder.json, or pass --map=path/to/file.json.

Every message of pull mode is listed in the pull mode reference.

In GitHub Actions

In CI, sign the job in to AWS with OIDC and use the Envilder Action. It sets up Node.js itself and, with env-file, writes the file. Without env-file it exports the values to the later steps of the job instead.

.github/workflows/ci.ymlyaml
name: CI
on: [push]

permissions:
  id-token: write # Required for OIDC
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
          aws-region: eu-west-1
      - uses: macalbert/envilder/github-action@v0
        with:
          map-file: envilder.json
          env-file: ${{ runner.temp }}/app.env
      - run: docker compose --env-file "$RUNNER_TEMP/app.env" up -d

The file goes to the runner temp directory, outside the workspace, so later build, cache or artifact steps do not pick it up. To avoid a file, use the Action's command input or envilder exec.