Skip to content

Instantly share code, notes, and snippets.

@futzlarson
Last active March 6, 2026 14:56
Show Gist options
  • Select an option

  • Save futzlarson/f243caf36776cea3356846e0cef2729f to your computer and use it in GitHub Desktop.

Select an option

Save futzlarson/f243caf36776cea3356846e0cef2729f to your computer and use it in GitHub Desktop.
Approaches to Environment Variables Beyond Vapor's 2KB Lambda Limit

Replacing Vapor's Encrypted Env Files: Approaches & Laravel Integration

Due to AWS Lambda limitations, Vapor environment variables are capped at ~2,000 characters. Vapor's built-in solution — encrypted environment files — works but has significant DX tradeoffs. This document evaluates alternatives with specific attention to how they integrate with Laravel's .envconfig() pipeline.


How .env Works in Laravel (and Why It Matters)

Understanding the boot sequence is critical to evaluating alternatives:

  1. .env is loaded very early — before service providers register. Values become available via env().
  2. config/*.php files reference env() — e.g., 'key' => env('GOTO_CLIENT_ID') in config/services.php.
  3. config:cache bakes everything into a single file — after this runs, env() returns null everywhere except inside config/*.php. Vapor runs config:cache during deployment.
  4. Service providers boot after config is resolved — by the time AppServiceProvider::boot() runs, all config() values from step 2 are already set.

This means any alternative to .env must inject values at the right point in the boot sequence, or they'll silently be null in production.


Why Not Encrypted Env Files (Vapor's Default)

The .env.{environment}.encrypted file is decrypted at deploy time and loaded in place of .env, so it fits perfectly into the boot sequence above. But:

  • No diffing — it's an encrypted blob in git history
  • Every change requires a commit + deploy — even a single API key rotation
  • Lag between "I need to change a value" and it being live
  • Secrets live in version control (encrypted, but still)
  • Team friction — only developers with the encryption key can view/edit values

TL;DR — What We Should Do Instead

Skip encrypted env files entirely. Use a hybrid of three layers, each matched to when the value is needed in Laravel's boot sequence:

Category Where Example Values
Bootstrap config (~10 values) Vapor env vars (plain, fits in 2KB) DB_HOST, REDIS_HOST, AWS_ACCESS_KEY_ID, APP_KEY
Secrets & API keys AWS SSM Parameter Store GOTO_CLIENT_SECRET, OPENAI_API_KEY, SLACK_BOT_USER_OAUTH_TOKEN
Runtime settings Database (optional) Feature flags, thresholds, notification preferences

Why this works: Bootstrap values fit within Lambda's 2KB limit on their own — the limit only hurts when you cram everything in. Secrets load from SSM via a service provider that writes to Laravel's config() at runtime, which works even after config:cache. Database settings load last, for values that non-engineers need to edit via Filament.

Performance tradeoff: SSM adds ~100-300ms to Lambda cold starts only (an API call to fetch parameters). Warm invocations are unaffected. Encrypted env files have zero runtime cost since they load at deploy time. This is the one genuine advantage they have over SSM — whether it matters depends on cold start frequency and latency sensitivity. Mitigations like Provisioned Concurrency or caching to Lambda's /tmp can reduce this further.

Cost: SSM Parameter Store is free. The integration is ~20 lines of PHP. No new infrastructure beyond what's already in the AWS account.

The alternatives below explain each layer in detail — how it integrates with Laravel's boot sequence, what it can and can't hold, and the tradeoffs.


Alternative 1: AWS Systems Manager Parameter Store

Key-value store built into AWS. Supports SecureString (encrypted) and hierarchical paths like /app/production/GOTO_CLIENT_SECRET.

How It Fits Into Laravel's Boot Sequence

Parameter Store values are fetched via API call, so they can only be injected after the app has enough config to make that call (AWS region + credentials). This means:

  • Bootstrap config (DB host, Redis, queue driver, AWS credentials) cannot live here — they're needed before you can reach SSM.
  • Everything else (API keys, tokens, feature flags) can be loaded in a service provider and written to config(), which works even after config:cache because you're writing to the runtime config repository, not reading env().

```php // AppServiceProvider::register() if (! $this->app->environment('local')) { $ssm = new SsmClient([ 'region' => config('services.ses.region'), 'version' => 'latest', ]);

\$result = \$ssm->getParametersByPath([
    'Path' => '/care-management/' . app()->environment() . '/',
    'WithDecryption' => true,
]);

foreach (\$result['Parameters'] as \$param) {
    \$key = str(\$param['Name'])->afterLast('/')->toString();
    // Maps SSM path to config key, e.g.:
    // /care-management/production/services.goto.client_id → config('services.goto.client_id')
    config([\$key => \$param['Value']]);
}

} ```

Key detail: This writes to Laravel's config repository at runtime. It works with config:cache because we're calling config([\$key => \$value]) (the setter), not env(). Code that reads config('services.goto.client_id') will get the SSM value.

What Changes in config/services.php

For values managed by SSM, you have two options:

Option A — Keep env() as fallback (good for local dev): ```php 'goto' => [ 'client_id' => env('GOTO_CLIENT_ID'), // null in prod (config:cache), overwritten by SSM 'client_secret' => env('GOTO_CLIENT_SECRET'), // null in prod, overwritten by SSM ], ``` SSM overwrites these at boot. Locally, .env still works. The only risk: if SSM fails to load in production, these are silently null.

Option B — No env(), SSM is the sole source: ```php 'goto' => [ 'client_id' => null, // populated by SSM at boot 'client_secret' => null, // populated by SSM at boot ], ``` Makes the dependency explicit. Pair with a health check that verifies SSM values loaded.

Performance

  • Cold starts: Each getParametersByPath call adds ~50-200ms. SSM returns max 10 parameters per page, so 25 parameters = 3 sequential API calls. Structure your SSM hierarchy to minimize round trips.
  • Warm invocations: Zero impact — config is already in memory from the cold start.
  • Availability risk: If SSM has a regional outage or is throttled, your app cannot boot. This is a hard dependency on the critical path.

Mitigations

  • Lambda Provisioned Concurrency — keeps instances warm so SSM calls only happen on scale-up, not routine requests. Adds cost.
  • Cache to /tmp — Lambda's /tmp directory persists across warm invocations within the same instance. Fetch from SSM on cold start, write to /tmp, read from /tmp on subsequent warm starts to avoid redundant calls if the instance partially recycles.
  • Batch efficiently — use a single path prefix (/care-management/production/) to fetch all params in as few API calls as possible.

Pros

  • Real-time updates — change a value, next Lambda invocation picks it up (no deploy)
  • Full audit trail via CloudTrail
  • Diffable — current values and history visible in AWS console
  • Free tier covers standard parameters (no cost for < 10,000)
  • Granular IAM — restrict who can read/write specific paths

Cons

  • Cold-start penalty — ~100-300ms of API calls on every Lambda cold start
  • External dependency — if SSM is down, your app can't boot
  • Requires IAM configuration on the Vapor/Lambda role
  • Cannot hold bootstrap config (DB, Redis, queue, AWS creds themselves)

Alternative 2: AWS Secrets Manager

Purpose-built for secrets with automatic rotation. Same boot-sequence constraints as Parameter Store.

How It Fits

Same as SSM — fetched via API in a service provider, written to config(). The only difference is the SDK client (SecretsManagerClient) and the response format. Secrets Manager stores JSON blobs rather than individual key-value pairs, so one secret can hold a group of related values.

```php $secret = json_decode( $client->getSecretValue(['SecretId' => 'care-management/production'])['SecretString'], true );

foreach ($secret as $key => $value) { config([$key => $value]); } ```

Performance

  • Same cold-start penalty as SSM (~50-200ms per API call), but potentially fewer round trips if you store all values in a single JSON secret rather than individual parameters.
  • Same availability risk — hard dependency at boot.

Pros

  • Built-in automatic rotation (DB passwords, API keys on a schedule)
  • Cross-account and cross-region replication
  • Designed specifically for secrets
  • Fewer API calls than SSM if grouping values into one secret

Cons

  • $0.40/secret/month + $0.05 per 10K API calls — cost adds up
  • Same cold-start penalty and bootstrap limitation as SSM
  • Overkill for non-secret config

Alternative 3: Database-Backed Settings

Store config in a `settings` table, loaded at boot and cached. `spatie/laravel-settings` provides a clean interface, and Filament has native settings page support.

How It Fits

This has the most restrictive boot-sequence constraint. Database settings require:

  1. A working database connection (needs DB host, user, password already in config)
  2. A booted application (migrations run, table exists)

This rules out any config needed before or during the database connection setup.

Performance

  • Adds a DB query on every request, not just cold starts. Mitigated by caching to Redis or file — when cached, the overhead is negligible.
  • If the cache is cold or Redis is slow, it's an extra round trip per request.
  • Unlike SSM, this cost is per-request rather than per-cold-start.

Suitable For

  • Feature flags, thresholds, toggles
  • Values non-engineers need to edit via a Filament panel
  • Config that changes frequently without deploys

Not Suitable For

  • Any credential or connection string — DB, Redis, queue, API keys
  • Anything needed during the boot sequence

Pros

  • Editable via admin UI (Filament settings pages)
  • No AWS-specific tooling
  • Non-technical team members can update values

Cons

  • Cannot hold bootstrap config, credentials, or anything sensitive
  • Adds a DB query per request (mitigated by caching)
  • Another migration/model/UI to maintain

Recommended Approach: Hybrid

Combine approaches based on what each value is and when it's needed:

Category Where Why Example Values
Bootstrap config Vapor env vars (the 2KB) Needed before anything else loads `DB_HOST`, `REDIS_HOST`, `AWS_ACCESS_KEY_ID`, `AWS_DEFAULT_REGION`, `APP_KEY`
Secrets & API keys SSM Parameter Store Auditable, updatable without deploy, free `GOTO_CLIENT_SECRET`, `OPENAI_API_KEY`, `SLACK_BOT_USER_OAUTH_TOKEN`, `STEDI_API_KEY`
Runtime settings Database (`spatie/laravel-settings`) Editable via Filament, no deploy or AWS console Feature flags, thresholds, notification preferences

How This Eliminates Encrypted Env Files

The 2KB Lambda env var limit is only a problem when you try to put everything in it. By offloading secrets to SSM and runtime config to the database, the Lambda env vars only need to hold bootstrap values — which comfortably fit within 2KB.

No encrypted env file needed. Plain Vapor env vars handle bootstrap, SSM handles secrets, database handles runtime config.

Performance Summary

Approach Cold Start Cost Per-Request Cost Availability Risk
Encrypted env files None (loaded at deploy) None None
SSM Parameter Store ~100-300ms (API calls) None SSM outage = app can't boot
Secrets Manager ~50-200ms (fewer calls if batched) None Same as SSM
Database settings None (loaded after boot) 1 DB query (cacheable) DB outage (already a dependency)

The cold-start cost of SSM is the primary tradeoff vs. encrypted env files. For most workloads this is acceptable — cold starts are infrequent relative to warm invocations. If cold-start latency is critical, Provisioned Concurrency or /tmp caching can reduce the impact.

Migration Path

  1. Audit current `.env` — categorize each value as bootstrap, secret, or runtime config
  2. Set up SSM paths — e.g., `/care-management/staging/`, `/care-management/production/`
  3. Add the SSM service provider logic — ~20 lines of code
  4. Move secrets to SSM — starting with the most frequently rotated
  5. Remove those values from Vapor env vars — they'll now load from SSM at boot
  6. Optionally add database settings for values that non-engineers need to control
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment