HAProxy fails to start with Docker Compose due to missing volume

Problem: HAProxy Fails to Start in Docker Compose Because the Certificate Volume Is Missing or Mis‑mounted

When launching a local development stack with docker-compose up, the HAProxy service aborts during initialization with errors similar to:

ERROR: cannot open /etc/haproxy/certs/example.com.pem: No such file or directory
HAProxy: SSL: unable to load certificate from /etc/haproxy/certs/example.com.pem (error 2)
docker-compose: service "haproxy" failed to start: mount source path does not exist: ./certs

These messages indicate that the container cannot locate the TLS certificate files that are expected at /etc/haproxy/certs. The root cause is almost always a bind‑mount problem: the host directory either does not exist, is referenced with a relative path that resolves incorrectly, or has permission/SELinux issues that prevent the HAProxy process (running as a non‑root user) from reading the files.

Root Cause Analysis

How HAProxy Loads TLS Certificates

According to the HAProxy Configuration Manual – TLS/SSL section, the crt directive expects a directory containing PEM‑encoded certificate bundles. HAProxy scans the directory at startup and aborts if it cannot open any file that matches the pattern (e.g., /etc/haproxy/certs/*.pem).

Docker Bind‑mount Mechanics

The Docker Engine documentation on bind‑mount permission handling explains that the container sees the host directory with the same UID/GID as the host file system unless :ro or other mount options are used. If the host directory is owned by root and HAProxy runs as UID 1001 (the default for the official HAProxy image), the container will encounter Permission denied (error 13).

Typical Mis‑configurations

  • Relative path resolved from the wrong working directory – Docker Compose resolves ./certs relative to the directory where docker-compose is invoked. Running the command from a sibling directory results in an empty mount (see the incident where “a relative path (./ssl) … resolved to an empty directory”).
  • Case‑sensitivity mismatch – On macOS or Windows, the host file system may be case‑insensitive, but the container’s Linux FS is case‑sensitive. A file named Example.Com.pem will not be found as example.com.pem inside the container, leading to “No such file or directory”.
  • Missing host directory after OS updates – Docker Desktop updates can change the mount root (//c/… vs /mnt/c/…) and break existing paths, as reported in the Windows‑WSL2 incident.
  • SELinux or Docker Desktop “cached” mount options – On SELinux‑enforcing hosts, the mount point must be relabeled (:z or :Z) or HAProxy will receive “Permission denied”. The Reddit post about “:cached” illustrates this.

Investigation and Debugging Steps

1. Verify the Compose Volume Definition

# docker-compose.yml (problematic snippet)
services:
  haproxy:
    image: haproxy:2.8-alpine
    ports:
      - "443:443"
    volumes:
      - ./certs:/etc/haproxy/certs

Check that ./certs exists on the host and contains the expected PEM files.

2. Inspect the Container’s View of the Mount

# Run a temporary shell in the same service definition
docker-compose run --rm haproxy ls -l /etc/haproxy/certs

Typical output when the mount fails:

ls: cannot access '/etc/haproxy/certs': No such file or directory

If the directory exists but is empty, the host path is wrong or the relative path was resolved incorrectly.

3. Check File Ownership and Permissions

# On the host
ls -l ./certs

Expected output:

-rw-r--r-- 1 user user 3245 Mar 12 10:23 example.com.pem

If the files are owned by root:root with 600 permissions, HAProxy (UID 1001) will hit error 13.

4. Capture the HAProxy Startup Log

# docker logs 
[ERROR] cannot open /etc/haproxy/certs/example.com.pem: No such file or directory
[ERROR] SSL: unable to load certificate from /etc/haproxy/certs/example.com.pem (error 2)

Match the exact path in the log with the mount point you defined.

5. Verify SELinux Context (Linux hosts only)

# ls -Z ./certs
system_u:object_r:container_file_t:s0 example.com.pem

If the context is not container_file_t, add the :z flag to the volume definition.

Solution: Correct the Volume Mount and Align Permissions

Before – Faulty Compose Definition

services:
  haproxy:
    image: haproxy:2.8-alpine
    ports:
      - "443:443"
    volumes:
      - ./certs:/etc/haproxy/certs

After – Robust Definition

services:
  haproxy:
    image: haproxy:2.8-alpine
    user: "1001"                # default non‑root user
    ports:
      - "443:443"
    volumes:
      - type: bind
        source: ${PWD}/certs      # absolute path guarantees correct resolution
        target: /etc/haproxy/certs
        read_only: true
        bind:
          propagation: rprivate
        # On SELinux‑enforcing hosts add :z to relabel, on macOS/Windows add :cached if needed
    environment:
      - HAPROXY_TLS_CERT_DIR=/etc/haproxy/certs

Adjust Host Permissions (if needed)

# Ensure files are readable by UID 1001
chmod 644 ./certs/*.pem
# Or change ownership to match container user
chown 1001:1001 ./certs/*.pem

On SELinux hosts:

# Apply shared label
chcon -Rt svirt_sandbox_file_t ./certs
# Or use the :z flag in the compose file (Docker will relabel automatically)

Why the Fix Works

  • Using an absolute source path eliminates ambiguity caused by running docker-compose from different directories.
  • Marking the mount read_only matches the HAProxy image’s expectation that certificates do not need to be written at runtime.
  • Correct file permissions (or running the container as root) satisfy the UID/GID mismatch described in the Docker Engine docs.
  • The :z (or :Z) SELinux flag ensures the kernel grants the container the required access, preventing “error 13”.

Verification – Confirm HAProxy Starts Successfully

1. Restart the Stack

docker-compose down -v
docker-compose up -d

2. Check Container Health

# docker ps -f name=haproxy
CONTAINER ID   IMAGE               COMMAND                  CREATED          STATUS          PORTS                      NAMES
a1b2c3d4e5f6   haproxy:2.8-alpine "docker-entrypoint.s…"   10 seconds ago   Up 9 seconds    0.0.0.0:443->443/tcp       myproject_haproxy_1

3. Verify No TLS Errors in Logs

# docker logs myproject_haproxy_1 2>&1 | grep -i ssl
# (no output – indicates successful certificate load)

4. Perform a TLS Handshake Test

openssl s_client -connect localhost:443 -servername example.com 

Expected snippet from the OpenSSL output:

...
Certificate chain
 0 s:/CN=example.com
   i:/CN=Example CA
...
SSL handshake has read 3245 bytes and written 456 bytes
Verification: OK

Prevention – Guardrails for Future Development

  • Use absolute paths or environment variables for bind mounts in docker-compose.yml. Example: source: ${PWD}/certs.
  • Version‑control the certificate directory structure (e.g., certs/example.com.pem) and enforce naming conventions that match HAProxy’s case‑sensitive expectations.
  • Integrate a CI check that runs docker-compose config and validates that the host path exists before pushing changes.
  • Enable a healthcheck that runs haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg inside the container; Docker will automatically restart the service if the check fails.
  • Document SELinux requirements for Linux developers and add the :z flag to the volume definition when the host enforces SELinux.
  • Pin the HAProxy image version (e.g., haproxy:2.8-alpine) to avoid unexpected changes in default user IDs.

FAQ – Common Follow‑up Questions

  1. Why does HAProxy start fine on Linux but fail on macOS?
    macOS uses a case‑insensitive file system and Docker Desktop’s file‑sharing layer can change path prefixes (e.g., //c/… vs /mnt/c/…). Ensure the host path is absolute and that certificate filenames match the case used in the HAProxy config.
  2. Can I keep HAProxy running as the default non‑root user and still read certificates owned by root?
    Yes, by either changing the host file permissions to be world‑readable (chmod 644) or by adding user: root to the service definition. The preferred approach is to adjust permissions because it preserves the security model of the official image.
  3. What does error 13 (“Permission denied”) indicate in the HAProxy log?
    It means the process could locate the file path but the kernel denied read access, typically due to mismatched UID/GID, SELinux restrictions, or the mount being read‑only without proper permissions.
  4. How can I debug a “No such file or directory” error when the file clearly exists on the host?
    Run docker-compose exec haproxy ls -l /etc/haproxy/certs to view the container’s perspective. Verify the bind‑mount source path, confirm that the compose command is executed from the correct working directory, and check for case‑sensitivity mismatches.
  5. Is it safe to mount the certificate directory as read‑only?
    Yes. HAProxy only reads certificates at startup; a read‑only bind‑mount prevents accidental modification from within the container while satisfying the image’s security expectations.

Related Topic Hub: Distributed Systems Troubleshooting Hub