The Reality of Default PHP Containers
I recently audited a high-traffic fintech application hosted on E2E Networks infrastructure in India. The stack was standard: PHP 8.2-FPM running on a Debian-based Docker image. During the initial reconnaissance, I found that the container was running as the root user, the entire filesystem was writable, and the .git directory was accidentally included in the image layers. This is the baseline for many deployments, and it is a security disaster waiting to happen.
Default Docker configurations prioritize "it just works" over "it is secure." In a production environment, especially one governed by the Digital Personal Data Protection (DPDP) Act 2023, these defaults are liabilities. A single Remote Code Execution (RCE) vulnerability in a PHP application—like the classic CVE-2019-11043 documented in the NIST NVD—can escalate from a simple process compromise to a full host breakout if the container is not properly hardened. We need to move beyond standard FROM php:latest instructions.
We observed that many Indian SMEs rely on default images because they lack the specialized DevOps security resources to customize them or provide secure SSH access for teams managing these environments. However, with the DPDP Act mandating "reasonable security practices" to protect personal data, continuing to run privileged containers is no longer just a technical risk; it is a legal one. If a breach occurs and the investigation reveals that the container was running as root with unnecessary kernel capabilities, the financial penalties (up to ₹250 crore) could be catastrophic.
Understanding Docker Container Hardening
What is Container Hardening?
Container hardening is the process of stripping away every component, capability, and permission that is not strictly required for the application to function. In the context of PHP, this means removing shell access, excluding build-time dependencies like gcc or make, and following SSH security best practices to ensure the PHP-FPM process cannot write to any directory other than specific temporary paths. We treat the container as a disposable, immutable execution environment rather than a mini-virtual machine.
We focus on reducing the "blast radius." If an attacker successfully exploits a vulnerability in your Laravel or Symfony code, hardening ensures they find themselves in a "jail." They should not be able to install new packages, scan the internal network, or access the Docker socket. Hardening is about making the environment so hostile to an attacker that their post-exploitation phase stalls immediately.
What is Docker Hardening in a DevOps Lifecycle?
Hardening is not a one-time configuration; it is a continuous process integrated into the CI/CD pipeline. We implement hardening at three distinct stages:
- Build Time: Selecting minimal base images and using multi-stage builds to keep the final image clean.
- Ship Time: Scanning images for known CVEs before they reach the registry.
- Run Time: Enforcing security profiles (Seccomp, AppArmor) and resource limits during container orchestration.
In our workflow, a developer pushes code, and the CI runner builds an image. Before that image is tagged for production, it must pass a vulnerability scan and a configuration audit. If the image contains a root user or high-severity vulnerabilities like CVE-2024-2961, the build fails. This ensures that security is a gate, not an afterthought, aligning with OWASP Top 10 standards.
The Core Principles of Docker Container Security Hardening
We adhere to the principle of Least Privilege. This applies to three specific areas of the container lifecycle:
- User Privilege: Never run as UID 0 (root). We map the application process to a high-numbered non-system user.
- Filesystem Privilege: The container filesystem should be
read-onlyby default. We only allow writes to specifictmpfsmounts or persistent volumes. - Kernel Privilege: Containers share the host kernel. We drop all default capabilities and only add back what is necessary, such as
NET_BIND_SERVICEif the process needs to bind to a privileged port.
Docker Image Hardening Best Practices
Starting with Docker Hardened Container Images
The choice of base image dictates the initial attack surface. Standard images like php:8.3-fpm are built on top of full Debian or Ubuntu distributions. These include package managers (apt), shells (bash), and various utilities (curl, sed, grep) that an attacker can use for lateral movement. We prefer Alpine Linux or Distroless images.
Alpine Linux uses musl libc instead of glibc. This is a critical security distinction. For example, CVE-2024-2961 is a critical buffer overflow in glibc's iconv() function that affects PHP applications. Because Alpine uses musl, it is inherently immune to this specific glibc-based vector. By simply switching the base image, we eliminate an entire class of vulnerabilities.
Checking for vulnerabilities in a standard PHP image
$ trivy image --severity HIGH,CRITICAL --ignore-unfixed php:8.3-fpm
Comparing with the Alpine version
$ trivy image --severity HIGH,CRITICAL --ignore-unfixed php:8.3-fpm-alpine
Minimizing Attack Surface with Distroless and Alpine Bases
Distroless images take hardening even further by removing everything—including the shell. If an attacker gains RCE, they cannot run ls, cd, or wget because those binaries don't exist. For PHP, however, Distroless is complex because PHP-FPM often requires specific shared libraries and configuration files. We find that a heavily stripped Alpine image is the sweet spot between security and maintainability.
When using Alpine, we ensure that even the apk package manager is removed or restricted after the build phase. This prevents an attacker from installing tools like nmap or socat to pivot into your internal VPC on AWS Mumbai or Azure India regions.
Implementing Multi-Stage Builds for Leaner Images
Multi-stage builds allow us to use a "heavy" image for compiling extensions and installing Composer dependencies, then copy only the necessary artifacts into a "lean" production image. This ensures that the production image does not contain git, unzip, or the PHPIZE_DEPS required for pecl installs.
Stage 1: Build environment
FROM php:8.3-fpm-alpine AS builder
RUN apk add --no-cache $PHPIZE_DEPS libpng-dev \ && docker-php-ext-install gd
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer WORKDIR /app COPY . . RUN composer install --no-dev --optimize-autoloader
Stage 2: Production environment
FROM php:8.3-fpm-alpine
Create a non-root user
RUN addgroup -g 1001 appgroup && \ adduser -u 1001 -G appgroup -s /bin/sh -D appuser
WORKDIR /var/www/html
Copy only the necessary files from the builder
COPY --from=builder --chown=appuser:appgroup /app /var/www/html
Hardening PHP-FPM to listen on a socket with restricted permissions
RUN sed -i 's/listen = 9000/listen = \/var\/run\/php-fpm.sock\nlisten.owner = appuser\nlisten.group = appgroup\nlisten.mode = 0660/' /usr/local/etc/php-fpm.d/zz-docker.conf
USER 1001 EXPOSE 9000 CMD ["php-fpm"]
Scanning for Vulnerabilities in Docker Image Hardening
We use Trivy or Grype to scan images during the CI process. It is important to filter for "fixed" vulnerabilities. Many base images contain vulnerabilities that have no fix yet; spending time on these is often counterproductive unless they are critical. We focus on the "low-hanging fruit" that attackers exploit first.
In the Indian context, where data localization is a priority under the DPDP Act, ensuring that your scanning tools are updated with the latest threat intelligence is vital. We recommend running a scan every time a new image is built and also on a weekly schedule for images already in the registry to catch "zero-day" vulnerabilities that were discovered after the image was deployed.
Hardening the Container Runtime Environment
Enforcing Non-Root User Execution
By default, Docker containers run as root. This means that if an attacker escapes the container, they have root access on the host machine. We enforce the USER directive in the Dockerfile. We also ensure that the UID/GID used inside the container does not overlap with sensitive users on the host.
When running the container, we can verify the user identity with the following command:
$ docker run --rm --user 1001:1001 php-app-hardened:latest id
Output: uid=1001(appuser) gid=1001(appgroup) groups=1001(appgroup)
If the output shows uid=0(root), the hardening has failed. For PHP applications, this non-root user must have ownership of the storage and cache directories (in Laravel) or the var directory (in Symfony), while the rest of the application code should be owned by root and only readable by the appuser.
Limiting Kernel Capabilities and System Calls
The Linux kernel has a set of "capabilities" that break down the power of the root user into smaller pieces. Docker, by default, grants several of these to containers (e.g., CHOWN, DAC_OVERRIDE, NET_RAW). Most PHP applications need none of these. We use the --cap-drop=ALL flag and only add back what is strictly necessary.
Running a PHP container with zero kernel capabilities and a read-only rootfs
$ docker run --cap-drop=ALL \ --read-only \ --tmpfs /tmp \ --tmpfs /var/run \ --user 1001:1001 \ php-app:latest
By using --read-only, we prevent an attacker from downloading a webshell or modifying the index.php file. The --tmpfs flags allow PHP to write session files or cache files to memory rather than the disk, which is a key step in hardening session security against tampering.
Configuring Resource Quotas to Prevent DoS Attacks
A common attack against PHP applications is a Denial of Service (DoS) by exhausting CPU or memory. Since PHP-FPM spawns multiple worker processes, a single unoptimized script can consume all host resources. We use Docker's resource constraints to limit this risk.
docker-compose.yml snippet for resource limits
services: php: image: php-app-hardened:latest deploy: resources: limits: cpus: '0.50' memory: 512M reservations: cpus: '0.25' memory: 256M
In our tests, setting these limits prevents a "noisy neighbor" effect where one compromised or buggy container crashes the entire node. For Indian startups using smaller VPS instances (1GB or 2GB RAM) on local providers, these limits are essential for maintaining uptime during traffic spikes or credential-stuffing attacks.
Operational Hardening and Lifecycle Management
Troubleshooting: Why is my Docker Container Not Stopping?
We often see containers that take 10 seconds to stop and are eventually killed by the Docker daemon. This happens because the application is not handling the SIGTERM signal correctly. In Docker, the process with PID 1 is responsible for handling signals. If you use a shell script as your ENTRYPOINT, the shell becomes PID 1 and often ignores signals, leaving the PHP process orphaned.
To fix this, we use the exec form in the Dockerfile and ensure that PHP-FPM is the primary process. This allows for "graceful shutdowns," where PHP finishes processing the current request before exiting, preventing data corruption or interrupted transactions—critical for UPI-linked payment flows.
Proper Signal Handling for Graceful Shutdowns
Always use the array syntax for CMD or ENTRYPOINT. This ensures the command is executed directly without a shell wrapper. If you must use a script, use exec to replace the shell process with the PHP process.
Incorrect: CMD "php-fpm" (Starts as /bin/sh -c "php-fpm")
Correct: CMD ["php-fpm"] (Starts as PID 1)
We also configure the STOPSIGNAL in the Dockerfile if the application uses a non-standard signal for shutdown. For PHP-FPM, the default SIGQUIT is usually appropriate for a graceful exit.
Managing Secrets and Environment Variables Securely
Never use the ENV instruction in a Dockerfile for sensitive data like database passwords or API keys for services like Razorpay. ENV variables are baked into the image layers and can be retrieved by anyone with access to the image using docker inspect.
Instead, we use Docker Secrets (in Swarm) or Kubernetes Secrets. For standalone Docker, we mount secrets as files into a tmpfs volume. PHP can then read these secrets from the filesystem. This ensures that the secrets never touch the disk and are not visible in the process list or image history.
Example of reading a secret in PHP
$db_password = trim(file_get_contents('/run/secrets/db_password'));
Continuous Hardening: Monitoring and Auditing
Automating Image Rescans
The security posture of an image changes every day as new CVEs are discovered. We automate the rescanning of our production images using a cron job or a CI pipeline. If a high-severity vulnerability is found in an image that is currently running in production, we trigger an automated alert to the security team.
For Indian organizations, maintaining an audit trail of these scans is a key component of demonstrating compliance during a DPDP Act audit. It proves that the "Data Fiduciary" took proactive steps to secure the processing environment.
Runtime Security Monitoring Tools
Even with a hardened image, we still need visibility into what the container is doing. We use tools like Falco to monitor for suspicious system calls. For example, if a PHP-FPM process suddenly tries to execute sh or modify a file in /etc, Falco will trigger an alert.
Example Falco rule to detect shell in a PHP container
- rule: Shell spawned in PHP container
desc: Detect a shell being started inside a PHP-FPM container condition: container.image.repository contains "php" and proc.name = "sh" output: "Unexpected shell spawned (user=%user.name container_id=%container.id)" priority: CRITICAL
This "Defense in Depth" approach ensures that even if an attacker bypasses our build-time and runtime hardening, their presence is detected immediately. We recommend sending these logs to a centralized SIEM for real-time threat detection to ensure they are not tampered with by an attacker who has gained host access.
Final Technical Insight
Hardening is not about a single flag; it is about the intersection of image composition and runtime constraints. A rootless container on a writable filesystem is still vulnerable; a root container on a read-only filesystem is still vulnerable. You must implement the "Holy Trinity" of container security: Non-root user, Read-only filesystem, and Dropped capabilities. This configuration significantly raises the cost of an attack, often forcing the adversary to look for easier targets elsewhere.
Final check: Inspecting security options of a running container
$ docker inspect --format='{{json .HostConfig.SecurityOpt}}' php-container-name
