Managing Environment Variables Securely
AI generated
OWASP
0x00
Security · Environment · Docker · Magento
Managing Environment Variables Securely
When a config file becomes a data leak

Environment variables separate configuration from code, but the same mechanism that makes secrets flexible also makes them exposed. Reachable phpinfo() calls, credentials baked into Docker images, and unprotected env.php files rank among the most common causes of data leaks in PHP and Magento projects. This article shows concrete countermeasures for Docker, CI/CD, and Magento.

14 min read Docker · Docker Compose · CI/CD Magento 2.4.8 · PHP 8.4

1. Environment variables vs. config files: understanding the tradeoffs

The Twelve-Factor App methodology recommends strictly separating configuration from code and providing it through environment variables, so the same codebase runs unchanged in development, staging, and production. The advantage is obvious: a deployment script simply sets different values without touching a single line of code or a versioned file. For database hosts, API endpoints, or feature flags, this is a sensible default that has proven itself equally well in Docker containers, Kubernetes pods, and classic application servers.

For secrets, this recommendation only holds with caveats. Environment variables are process-scoped, unversioned, and therefore hard to audit centrally: who set which value when is nearly impossible to trace without extra tooling. They also surface in places rarely considered, such as /proc/self/environ, the output of ps auxe, or crash dumps produced by process managers. Config files, by contrast, can be encrypted, given access permissions, and traced through code review. Magento itself takes a hybrid approach: secrets originate from environment variables or CLI parameters at install time, but end up structured in the PHP file app/etc/env.php, not in a raw .env file.

The pragmatic rule is therefore: use environment variables for bootstrapping, environment identity, and non-sensitive configuration, but prefer encrypted config files or a dedicated secrets vault for structured or especially critical secrets. Neither mechanism excludes the other, they complement each other once their respective weaknesses are understood.

2. Accidental exposure via phpinfo(), error pages, and debug output

A reachable phpinfo() in production is one of the most underestimated vulnerabilities out there, because it renders the entire content of the superglobals $_ENV and $_SERVER unencrypted into a publicly accessible HTML page. Automated scanners specifically hunt for paths like /phpinfo.php, /info.php, or /test.php, which were created during development and forgotten at deployment time. A single overlooked debug endpoint can expose database credentials, API keys, and internal hostnames in one shot.

Less obvious, but just as dangerous, are debug outputs in error pages. Symfony Whoops stack traces, unhandled exceptions with display_errors enabled, or an accidental var_dump($_SERVER) in a custom block reveal the same scope of data, just less structured. Xdebug output in production environments is a related risk, since it delivers the full request context, including the environment, the moment an error occurs.

The most effective countermeasure combines several layers: hard-disable phpinfo via disable_functions in the production php.ini, enforce display_errors=Off and MAGE_MODE=production, and use logging wrappers that only log an explicit allowlist of keys instead of blanket-serializing request or environment objects.


<?php

declare(strict_types=1);

namespace Mironsoft\EnvSecurity\Controller\Diagnostics;

/**
 * Dangerous pattern: never expose phpinfo() or raw superglobals in a
 * reachable controller action, even temporarily during development.
 */
final class DangerousDebugEndpoint
{
    public function execute(): void
    {
        // NEVER DO THIS in a reachable action, not even behind a route guess.
        // phpinfo() dumps every environment variable, including DB credentials.
        phpinfo();

        // Equally dangerous: dumping the raw environment or server superglobal.
        var_dump($_ENV, $_SERVER);
    }
}

/**
 * Safe pattern: read only the specific value needed, through a wrapper that
 * never logs or renders the full environment and redacts known secret keys.
 */
final class SafeEnvironmentReader
{
    private const SECRET_KEY_PATTERN = '/password|secret|key|token/i';

    /**
     * Returns a single environment value or a default, never the full set.
     *
     * @param string $name Name of the environment variable to read.
     * @param string|null $default Fallback value if the variable is unset.
     * @return string|null The resolved value or the provided default.
     */
    public function get(string $name, ?string $default = null): ?string
    {
        $value = getenv($name);

        return $value === false ? $default : $value;
    }

    /**
     * Builds a redacted snapshot safe for diagnostic logging.
     *
     * @param array<string, string> $env Raw environment key-value pairs.
     * @return array<string, string> Redacted copy safe to log or display.
     */
    public function redactedSnapshot(array $env): array
    {
        foreach ($env as $key => $value) {
            if (preg_match(self::SECRET_KEY_PATTERN, $key) === 1) {
                $env[$key] = '***REDACTED***';
            }
        }

        return $env;
    }
}

3. .env files in Docker, CI/CD, and the Mark Shust setup

The term .env file means something different depending on context, and precisely that ambiguity regularly causes misconfiguration. Docker Compose automatically reads a .env file at the project root to interpolate ${VARIABLE} placeholders inside the compose.yaml itself, which is a different thing from the env_file directive that actually injects values into the container. Anyone who conflates the two quickly wonders why a change to one file has no effect at all.

In CI/CD pipelines, no physical .env file should generally exist in the job's working directory at all. GitHub Actions secrets, GitLab CI/CD variables, or the equivalent on whichever platform inject values directly as the runner's process environment, which is discarded after the job anyway. Ephemeral runner environments are a built-in security advantage here, one that placing a .env file in the repository would needlessly undermine.

The Mark Shust setup underlying this project deliberately avoids a single global .env, using instead an env/ directory with topically separated files such as db.env, magento.env, redis.env, or rabbitmq.env, each assigned to the matching service in the compose file via env_file. This separation reduces the blast radius of a single leaked file and makes reviews clearer, but it does not replace the requirement to consistently exclude the entire env/ directory from version control.


#!/usr/bin/env bash
# scan-env-leaks.sh - quick local audit for the Mark Shust docker-magento env/ layout
set -euo pipefail

ENV_DIR="env"
SECRET_PATTERN="PASSWORD|SECRET|KEY|TOKEN"

echo "[CHECK] env/ directory must never be tracked by git"
if git ls-files --error-unmatch "${ENV_DIR}" >/dev/null 2>&1; then
  echo "FAIL: files under ${ENV_DIR}/ are tracked in git, remove them and rotate secrets"
  exit 1
fi

echo "[CHECK] scanning env/*.env files for obviously weak defaults"
for file in "${ENV_DIR}"/*.env; do
  if grep -Eiq "${SECRET_PATTERN}=(magento|password|changeme|admin)$" "${file}"; then
    echo "WARNING: ${file} contains a default or weak credential"
  fi
done

echo "[CHECK] no .env file should exist in a CI job working directory"
if [ -n "${CI:-}" ] && [ -f ".env" ]; then
  echo "FAIL: .env file present in CI, secrets must be injected via the CI secret store"
  exit 1
fi

echo "[OK] no obvious environment file leaks detected"

4. Precedence and accidental overrides between shell, Docker, and CI

Docker Compose resolves ${VARIABLE} placeholders inside the YAML file against the shell environment first, before ever falling back to the project-wide .env file. The shell wins during interpolation. Once the values for the container are settled, a second, independent precedence order applies: a service's environment: block always overrides values from env_file, and both override an ENV instruction baked into the image via the Dockerfile. Two separate precedence systems that are easy to confuse.

That leads to a real, recurring failure pattern: a developer runs export DB_PASSWORD=test locally for debugging, forgets to unset it, and the next docker compose up silently interpolates that value over the configuration actually stored in env/db.env, with no error message shown. The same effect can occur in CI pipelines with reused runners, when a previous job exports environment variables that were never cleaned up before the next job starts.

The most reliable protection is to make the actually resolved state visible before every deployment, rather than relying on memorized precedence rules. docker compose config renders the fully resolved compose configuration including every interpolated value, making overrides immediately obvious before they reach production. A brief manual look at this output before every docker compose up in a production-adjacent environment is far more reliable than trusting the correct order to memory.

Similar caution applies to CI systems with cached or reused job containers: a build cache that preserves a previous environment variable can silently resurface in a later, seemingly unrelated job. Clean, isolated runner instances per job, combined with an explicit env -i for critical deployment scripts, close this gap far more reliably than any amount of documentation.

5. Magento's app/etc/env.php: credentials, crypt key, and cache backends

app/etc/env.php is generated by setup:install and is not a .env file in the classic sense, it is a PHP array included directly by Magento's bootstrap. Confusing the two concepts is common, but consequential: anyone parsing a generic .env file with a package like vlucas/phpdotenv and assuming it covers Magento's configuration overlooks the fact that the truly critical values, database credentials, the crypt key, and cache backend configuration, live exclusively in env.php.

The crypt key is especially critical because it encrypts sensitive database columns, such as stored API credentials from payment modules or integration tokens. Whoever compromises the crypt key can decrypt every value encrypted with it in the database, even retroactively, if a database dump was captured as well. Redis or Valkey credentials for cache and session backends also sit in plain text within this same file.

env.php must therefore carry restrictive file permissions (640 is common) and an owner that is not the web server's process user, wherever the deployment model allows that separation. Backup files such as env.php.bak or env.php.old, which some deployment scripts accidentally leave inside the web root, are an underestimated leak vector, because a web server misconfiguration can serve them as plain text instead of interpreting them as PHP.


<?php
// app/etc/env.php (excerpt) - generated by bin/magento setup:install
// Must never be committed to version control or served as a static file.
return [
    'backend' => [
        'frontName' => 'admin',
    ],
    'crypt' => [
        // Encrypts sensitive DB columns (payment tokens, integration secrets).
        // Compromise of this value allows decrypting all affected data.
        'key' => 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
    ],
    'db' => [
        'connection' => [
            'default' => [
                'host' => 'db',
                'dbname' => 'magento',
                'username' => 'magento',
                'password' => 'REPLACE_WITH_STRONG_GENERATED_SECRET',
                'engine' => 'innodb',
            ],
        ],
    ],
    'cache' => [
        'frontend' => [
            'default' => [
                'backend' => 'Cm_Cache_Backend_Redis',
                'backend_options' => [
                    'server' => 'redis',
                    'port' => '6379',
                    'password' => 'REPLACE_WITH_STRONG_GENERATED_SECRET',
                ],
            ],
        ],
    ],
    'MAGE_MODE' => 'production',
];

6. Injecting secrets at runtime instead of baking them into the image

A secret baked into an image via ARG or ENV in a Dockerfile stays there permanently, even if a later layer instruction appears to overwrite or delete the value. Anyone with access to the image registry, or who gets hold of a leaked image, can read the complete build history, including every secret ever set, via docker history or a simple layer extraction tool. The same applies to a file copied into the image via COPY .env ..

The secure approach injects secrets only when the container starts or is deployed, never when the image is built. In practice that means: --env-file on a docker run call, Compose- or Swarm-native secrets:, or an orchestrator's own secret store such as Kubernetes Secrets, provided as tmpfs mounts. In CI/CD pipelines, a dedicated deploy step handles this by pulling values from a vault such as HashiCorp Vault or AWS Secrets Manager just before rollout and injecting them, instead of storing them in the pipeline code itself.

For this project, that means concretely: the deployment script reads secrets from a password manager or secrets file kept outside the repository, only immediately before docker compose up, and writes them into the env/ files on the target server, never into the image build context. Multi-stage builds additionally ensure that secrets needed during the build stage, for instance a private Composer repository token, do not accidentally leak into the final runtime layer.


# .github/workflows/deploy.yml - inject secrets at deploy time, never bake them in
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.1

      # Build the image WITHOUT any secrets in the build context.
      - name: Build application image
        run: docker build -t registry.example.com/magento-app:${{ github.sha }} .

      - name: Push image to registry
        run: docker push registry.example.com/magento-app:${{ github.sha }}

      # Secrets are pulled from the CI secret store and injected only now,
      # at deploy time, directly into the target host's env/ directory.
      - name: Inject secrets and deploy
        env:
          DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
          CRYPT_KEY: ${{ secrets.PROD_CRYPT_KEY }}
          REDIS_PASSWORD: ${{ secrets.PROD_REDIS_PASSWORD }}
        run: |
          ssh deploy@production "cat > /srv/app/env/db.env" <<EOF
          MYSQL_PASSWORD=${DB_PASSWORD}
          EOF
          ssh deploy@production "docker compose -f compose.yaml up -d"

7. environment, env_file, and secrets in docker-compose.yaml

Docker Compose offers three different mechanisms for getting values into a container, and they differ significantly in their security impact. The environment: block writes key-value pairs directly into the YAML file, visible to anyone with read access to the compose file, and additionally readable at any time via docker inspect, including by monitoring agents that collect container metadata. It is unsuitable for highly sensitive values.

env_file: loads values from an external file into the container environment, has the same runtime visibility via docker inspect and the process environment as environment:, but at least keeps the values out of the compose file itself, which eases version control and review as long as the file is properly ignored. The native secrets: mechanism, usable via file-based secrets since Compose v2 even without Swarm mode, mounts values as files under /run/secrets/, not as a process environment variable, and is therefore visible neither via docker inspect nor via ps inside the container.

The practical rule of thumb: environment: for non-critical configuration like APP_ENV or timezone settings, env_file: for developer-friendly, consistently gitignored files, and secrets: for anything an attacker with container inspection access, but no direct filesystem access, should not be able to see, such as database passwords or the Magento crypt key.


# compose.yaml - environment vs. env_file vs. secrets, side by side
services:
  phpfpm:
    image: mironsoft/magento-phpfpm:8.4
    environment:
      # Non-sensitive: safe to keep inline, visible in docker inspect.
      APP_ENV: production
      TZ: Europe/Berlin
    env_file:
      # Convenience layer for developer-oriented config, must be gitignored.
      - env/magento.env
    secrets:
      # Only referenced by name here, actual value never touches this file.
      - db_password
      - crypt_key

  db:
    image: mysql:8.0
    env_file:
      - env/db.env
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
  crypt_key:
    file: ./secrets/crypt_key.txt

8. .gitignore discipline for environment files

The baseline of an effective .gitignore for this setup includes at minimum .env, .env.* with an explicit exception for !.env.example, the entire env/*.env pattern for the Mark Shust layout, and app/etc/env.php. On the latter, many teams are uncertain: the common practice is to regenerate env.php per environment through setup:install or a deployment pipeline, and version only a documented env.php.example with placeholders, while versionable, non-sensitive configuration goes through bin/magento app:config:import and app/etc/config.php.

.gitignore alone only prevents future, accidental commits, it does not protect against human error with an explicit git add -f. That is why pre-commit hooks using tools like gitleaks or git-secrets belong in every serious pipeline, scanning every staged diff against known credential patterns before the commit is even created. Since local hooks can be bypassed with --no-verify, the same scan should additionally run as a mandatory CI step on every push.

If a secret ends up in Git history despite all precautions, deleting the file in the next commit is not enough, the value remains retrievable in every cloned repository and every fork. Tools like BFG Repo-Cleaner or git filter-repo remove the value from history after the fact, but never replace immediate rotation of the affected secret, because rewriting history does not undo access that already happened. A maintained .env.example or env.php.example with descriptive placeholders also documents which variables a new team member actually needs, without exposing real values.

9. Environment variable security compared

The following overview contrasts five typical misconfigurations in handling environment variables with their respective secure countermeasures, as they regularly show up in Docker, CI/CD, and Magento contexts.

Area Insecure behavior Secure countermeasure
Docker image Secrets baked into the Docker image Inject secrets at runtime
Version control .env committed to Git .env in .gitignore, .env.example as a template
Magento env.php DB credentials in plain text with no access protection env.php with 640 file permissions outside web-root access
Debug endpoints phpinfo() reachable in production phpinfo() disabled and removed in production
Environment separation One global .env for all environments Separate .env per environment with different secrets

No single item in this table solves the problem on its own. Only the combination of runtime injection, consistent .gitignore discipline, restrictive file permissions for env.php, disabled debug endpoints, and cleanly separated environments reduces the attack surface to a realistic minimum.

Mironsoft

Secrets audits, Docker hardening, and CI/CD hardening for Magento projects

Want to secure environment variables in your project?

We review your env.php, Docker Compose, and CI/CD configuration for leak vectors, set up runtime secret injection, and close the typical precedence traps between shell, Docker, and deployment pipeline.

Secrets audit

Full review of env.php, env/ files, and Docker Compose for leak risks

Docker hardening

Runtime injection instead of baked-in secrets, correct use of native Compose secrets

CI/CD hardening

Secret scanning, pinned pipelines, and documented rotation processes

10. Summary

Environment variables are not an automatically secure replacement for config files, they are a tool with their own specific weaknesses. Reachable phpinfo() calls, debug output, and forgotten diagnostic endpoints rank among the most common real-world leak vectors, because they expose the entire environment content unencrypted. Magento's app/etc/env.php is structurally different from a generic .env file and deserves special protection through restrictive file permissions and consistent exclusion from version control, given the crypt key and database credentials it holds.

The single most effective technical measure remains injecting secrets exclusively at runtime instead of baking them into the Docker image, combined with correctly using Docker Compose's secrets: mechanism for highly sensitive values. Precedence rules between shell, .env, env_file, and environment: should never be reconstructed from memory, they should be made visible with docker compose config before every deployment. Consistent .gitignore discipline, complemented by automated secret scanning, closes the remaining gap between theory and actual team behavior.

Environment variable security: the key takeaways

Know the leak vectors

phpinfo(), debug output, and error pages often expose the full environment content.

env.php is not .env

Magento's crypt key and DB credentials live structured in env.php, never in a generic .env file.

Runtime, not build time

Never bake secrets into the Docker image, always inject them at start or deploy time.

gitignore plus scanning

.gitignore only prevents future commits, gitleaks in a CI pipeline step catches the rest.

11. FAQ: Managing Environment Variables Securely

1What is the difference between a .env file and app/etc/env.php?
.env is a generic format, env.php is a PHP array included by Magento's bootstrap holding structured, Magento-specific values like DB credentials and the crypt key.
2Why is phpinfo() dangerous in production?
It renders the entire content of $_ENV and $_SERVER unencrypted as HTML, and is specifically targeted by automated scanners.
3Should a .env file ever be committed to the Git repository?
No, only a .env.example with placeholders. The real .env with actual values stays consistently excluded via .gitignore.
4How does precedence work between shell, Docker Compose, and .env?
The shell wins during placeholder interpolation. At runtime, environment: always overrides env_file, and both override an ENV instruction baked into the Dockerfile.
5What are Docker Compose secrets and how do they differ from environment?
secrets: mounts values as files under /run/secrets/, not as a process environment variable, so they are invisible to docker inspect.
6Why should secrets not be baked into the Docker image?
They stay permanently in the layer history even after an apparent deletion, and can be extracted via docker history.
7What file permissions should env.php have?
Restrictive permissions such as 640 are common, ideally with an owner other than the web server's process user.
8What is the crypt key in Magento and why is it critical?
It encrypts sensitive DB columns like API credentials. If compromised, every value encrypted with it can be decrypted.
9How do I prevent secrets from being committed accidentally?
.gitignore as the first layer, plus pre-commit hooks like gitleaks and a mandatory secret scan as a CI step.
10How does the Mark Shust setup handle environment variables?
Via an env/ directory with separate files such as db.env or magento.env, each assigned to the matching service via env_file and fully excluded from Git.