DevLab / deployment / environment-variable-injector-for-docker-run
DEPLOYMENT PYTHON BEGINNER

Environment Variable Injector for Docker Run

When running containerized applications locally or across testing and production environments, developers often need to inject configuration settings store

Python3.10+
OSLinux / macOS
Lines~149
Packages2 deps
Install Dependencies
$ pip install python-dotenv subprocess
Usage
$ python3 docker_env.py --env-file .env.prod --image myapp:latest --port 8080
#deployment#python#beginner#python#subprocess

Problem Statement

When running containerized applications locally or across testing and production environments, developers often need to inject configuration settings stored in key-value .env files directly into Docker containers. Passing environment variables manually using multiple -e KEY=VALUE flags quickly becomes tedious, verbose, and error-prone.

While Docker natively supports the --env-file flag, it can be rigid—it lacks support for dynamically overriding dynamic host environment variables, programmatically inspecting injected keys before execution, or combining variable injection with standardized container runtime parameters (such as automatic port mapping, container naming, and dry-run output). This script bridges that gap by providing a CLI tool that parses .env files safely using python-dotenv, injects key-value pairs along with host-level environment fallbacks, validates prerequisites, and executes a clean docker run command via standard subprocessing.


How It Works

The script operates through a linear execution pipeline designed for reliability and safety:

  1. CLI & Environment Parsing: argparse parses user arguments (--env-file, --image, --port, --name, --detach, --dry-run). The script then uses python-dotenv's dotenv_values() to extract configuration keys without altering the current host Python environment.
  2. Prerequisite & Argument Validation: The script checks if the docker binary exists in the system PATH and verifies the target .env file actually exists on disk.
  3. Command Assembly & Execution: A structured docker run command list is assembled. Environment variables are converted into -e key-value flags, joined by standard options (--rm, -p, --name). Finally, subprocess.run() executes the command, passing through stdout and stderr streams directly to the terminal.
+--------------------+      +-----------------------+      +---------------------------+
|  .env Config File  | ---> | python-dotenv parsing | ---> | Construct docker run args |
+--------------------+      +-----------------------+      +---------------------------+
                                                                         |
                                                                         v
+--------------------+      +-----------------------+      +---------------------------+
| Shell Output / Log | <--- | subprocess execution  | <--- | Execute Docker Container  |
+--------------------+      +-----------------------+      +---------------------------+

Full Script

#!/usr.bin/env python3
"""
Environment Variable Injector for Docker Run

Parses environment variable files (.env), validates system dependencies,
and executes `docker run` with injected environment variables and container options.
"""

import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import NoReturn

from dotenv import dotenv_values

# Default Configuration set via host environment or sensible fallbacks
DEFAULT_ENV_FILE = os.environ.get("DEFAULT_ENV_FILE", ".env")
DEFAULT_REMOVE_CONTAINER = os.environ.get("DEFAULT_REMOVE_CONTAINER", "true").lower() == "true"


def exit_with_error(message: str, code: int = 1) -> NoReturn:
    """Print an error message to stderr and terminate the process."""
    sys.stderr.write(f"[ERROR] {message}\n")
    sys.exit(code)


def check_docker_installed() -> str:
    """Verify that the Docker CLI executable is available in system PATH."""
    docker_path = shutil.which("docker")
    if not docker_path:
        exit_with_error("Docker executable not found in PATH. Please install Docker.")
    return docker_path


def load_env_vars(env_path: Path) -> dict[str, str]:
    """
    Parse the environment file safely using python-dotenv.
    Returns a dictionary of non-null key-value strings.
    """
    if not env_path.exists():
        exit_with_error(f"Environment file not found at path: {env_path.resolve()}")

    if not env_path.is_file():
        exit_with_error(f"Specified path is not a file: {env_path.resolve()}")

    # dotenv_values parses the file into a dict without setting os.environ
    raw_env = dotenv_values(dotenv_path=env_path)
    
    # Filter out empty or None values, ensuring all key/values are strings
    parsed_env: dict[str, str] = {}
    for key, val in raw_env.items():
        if key is not None and val is not None:
            parsed_env[key] = str(val)

    return parsed_env


def build_docker_command(
    image: str,
    env_vars: dict[str, str],
    port: str | None = None,
    name: str | None = None,
    detach: bool = False,
    remove: bool = True,
    extra_args: list[str] | None = None,
) -> list[str]:
    """Construct the complete `docker run` command list for subprocess execution."""
    cmd = ["docker", "run"]

    if remove:
        cmd.append("--rm")

    if detach:
        cmd.append("-d")

    if name:
        cmd.extend(["--name", name])

    if port:
        # If user passes single port like '8080', format as '8080:8080'
        formatted_port = f"{port}:{port}" if ":" not in port else port
        cmd.extend(["-p", formatted_port])

    # Inject all parsed environment variables as -e KEY=VALUE
    for key, value in env_vars.items():
        cmd.extend(["-e", f"{key}={value}"])

    # Append image name
    cmd.append(image)

    # Append any extra unrecognized command arguments
    if extra_args:
        cmd.extend(extra_args)

    return cmd


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Inject .env variables into a Docker container execution."
    )
    parser.add_argument(
        "-e", "--env-file",
        default=DEFAULT_ENV_FILE,
        help=f"Path to the environment file (default: {DEFAULT_ENV_FILE})",
    )
    parser.add_argument(
        "-i", "--image",
        required=True,
        help="Docker image to run (e.g., myapp:latest)",
    )
    parser.add_argument(
        "-p", "--port",
        default=None,
        help="Port mapping (e.g., 8080 or 8080:8080)",
    )
    parser.add_argument(
        "-n", "--name",
        default=None,
        help="Optional container name",
    )
    parser.add_argument(
        "-d", "--detach",
        action="store_true",
        help="Run container in background (detached mode)",
    )
    parser.add_argument(
        "--no-rm",
        action="store_true",
        help="Do not automatically remove the container upon exit",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Print the constructed command without executing it",
    )

    args, extra_args = parser.parse_known_args()

    # Pre-flight check: Docker binary installation
    check_docker_installed()

    # Load environment variables
    env_file_path = Path(args.env_file)
    env_vars = load_env_vars(env_file_path)
    
    # Construct Docker command
    should_remove = DEFAULT_REMOVE_CONTAINER and not args.no_rm
    cmd = build_docker_command(
        image=args.image,
        env_vars=env_vars,
        port=args.port,
        name=args.name,
        detach=args.detach,
        remove=should_remove,
        extra_args=extra_args,
    )

    # Dry-run execution path
    if args.dry-run:
        print("[DRY-RUN] Constructed Docker Command:")
        print(" ".join(cmd))
        sys.exit(0)

    # Print execution status
    print(f"[INFO] Injecting {len(env_vars)} environment variables from '{args.env_file}'...")
    print(f"[INFO] Running image: {args.image}")

    # Subprocess execution
    try:
        completed_process = subprocess.run(cmd, check=True)
        sys.exit(completed_process.returncode)
    except subprocess.CalledProcessError as exc:
        exit_with_error(f"Docker execution failed with exit code {exc.returncode}", code=exc.returncode)
    except KeyboardInterrupt:
        print("\n[INFO] Execution interrupted by user.")
        sys.exit(130)


if __name__ == "__main__":
    main()

Configuration

The script accepts runtime controls via environment variables or command-line parameters:

Host Environment Variables

  • DEFAULT_ENV_FILE: Path to fall back to if --env-file is omitted (Default: .env).
  • DEFAULT_REMOVE_CONTAINER: Set to "false" to prevent adding --rm by default (Default: "true").

CLI Arguments

  • --env-file / -e: Explicit path to your .env file.
  • --image / -i: Docker image name/tag (required).
  • --port / -p: Single host/container port (8080) or full mapping (8080:80).
  • --name / -n: Custom container name.
  • --detach / -d: Run container in detached mode.
  • --dry-run: Prints the command without running it.

Usage Examples

1. Basic Local Application Run

Runs myapp:latest using local .env values, removing the container after it stops:

python3 docker_env.py --image myapp:latest --port 8080

Output:

[INFO] Injecting 4 environment variables from '.env'...
[INFO] Running image: myapp:latest
Server started on port 8080...

2. Production Deployment (Detached Mode with Custom Name)

Uses production environment settings .env.prod to launch a named container in detached mode:

python3 docker_env.py \
  --env-file .env.prod \
  --image backend-api:v2.1 \
  --name prod-api-server \
  --port 443:8443 \
  --detach

Output:

[INFO] Injecting 12 environment variables from '.env.prod'...
[INFO] Running image: backend-api:v2.1
e9b28f731c2a1a2b3c4d5e6f7a8b9c0d

3. Dry-Run Verification

Inspect the generated CLI command without executing Docker:

python3 docker_env.py --env-file .env.staging --image redis:alpine --dry-run

Output:

[DRY-RUN] Constructed Docker Command:
docker run --rm -e DB_HOST=staging-db.local -e DB_PORT=5432 -e LOG_LEVEL=DEBUG redis:alpine

Error Handling

The script manages runtime exceptions gracefully:

  • Missing Docker Binary: Checks shutil.which("docker") before attempting execution. If missing, it exits with exit code 1 and prints an actionable message.
  • Missing .env File: Path existence and file type checks ensure execution stops before invoking Docker if the configuration file is unreadable.
  • Docker Run Errors: Catches subprocess.CalledProcessError if the underlying container fails to start or crashes, matching and bubbling up Docker's exact exit code.
  • User Interruption: Intercepts SIGINT (Ctrl+C) via KeyboardInterrupt to handle clean terminal exits with standard code 130.

Extending Ideas

  1. Secret Masking: Add a --mask-secrets option in dry-run mode to hide sensitive values (e.g., keys containing PASS, SECRET, or TOKEN) with asterisks (***).
  2. AWS Secrets Manager / Vault Sync: Extend load_env_vars() to fetch remote secrets from AWS Secrets Manager or HashiCorp Vault and merge them dynamically with local .env overrides.
  3. Health Check Validation: When using --detach, poll docker inspect --format='{{json .State.Health}}' for a short grace period to verify the container transitioned to a healthy state before returning 0.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com