Caddy + Portainer + Docker: Minimal Reverse Proxy Setup for a Stable Production Environment

Caddy + Portainer + Docker: Minimal Reverse Proxy Setup for a Stable Production Environment

Caddy is a modern web server that is often used as a reverse proxy in Docker environments. It is lightweight, fast, and supports automatic HTTPS out of the box. When combined with Docker and Portainer, Caddy can be a clean alternative to Nginx or Nginx Proxy Manager — but only if it is configured correctly.

Most problems with Caddy in Docker are not caused by the server itself, but by incorrect volume mounts, missing file permissions, or unclear separation between the host filesystem and containers. This becomes especially visible when Caddy is managed through Portainer, where configuration files and logs are often assumed to be “handled automatically”.

In this article, you will learn how to install and correctly configure Caddy as a Docker reverse proxy, using a clear and minimal Docker Compose setup. The configuration is designed to work cleanly with Portainer, where the Caddyfile is mounted directly from the host machine, and Caddy logs and state are stored in Docker volumes.

The goal is not to build a complex setup, but to establish a stable baseline:

  • Caddy runs in Docker
  • configuration is fully controlled from the host
  • ports 80 and 443 are exposed correctly
  • file permissions are predictable
  • the container can be debugged easily through logs

Once this base setup is working, it can be safely extended with HTTPS, reverse proxy rules, and production services without breaking unexpectedly.

One Rule Before Anything Else: Test Caddy on Plain HTTP

Before configuring HTTPS, reverse proxy rules, or Portainer stacks, Caddy must respond on plain HTTP.
If Caddy does not serve traffic on port 80, adding TLS, domains, or Docker networking will only hide the real problem.

This applies to any Caddy Docker reverse proxy setup, especially when Caddy is managed through Portainer. HTTP is the baseline. If it fails, stop and fix it before moving forward.

Create the Caddyfile Before Running Docker

The Caddy configuration must exist before the container is started.

  • Not inside Portainer
  • Not generated by Docker
  • Not created automatically

The Caddyfile should live on the host filesystem, where permissions and paths are fully under your control. This is critical for predictable behavior when using Caddy with Docker Compose and Portainer.

Create a directory anywhere you control permissions:

mkdir -p /your/directory/caddy

Create the Caddyfile manually:

nano /your/directory/caddy/Caddyfile

This directory will be mounted into the Caddy container and used as the single source of truth for the Caddy configuration.

Minimal config:

:80 {
    respond "Caddy OK"
}

This is not an example config.
This is a sanity check.

File Permissions: Where Most Caddy Docker Setups Fail

Incorrect file permissions are one of the most common reasons a Caddy Docker container fails to start or behaves unpredictably. When Caddy is deployed through Portainer, these issues are often missed because the container may appear to be running while silently failing to load the configuration.

Set permissions explicitly on the host before starting Docker:

$ chmod 755 /your/directory/caddy
$ chmod 644 /your/directory/caddy/Caddyfile

  • Docker must be able to read the Caddyfile
  • Caddy does not need write access to the configuration
  • Using 777 does not fix the problem — it only hides permission errors and creates security issues

Minimal Docker Compose for Caddy

This Docker Compose file runs Caddy in Docker with a clean, predictable layout. It is suitable for both CLI usage and Portainer stacks, without relying on labels, auto-generated configs, or image defaults.

version: "3.9"

services:
  caddy:
    image: caddy:2.10.2-alpine
    container_name: caddy
    restart: unless-stopped

    ports:
      - "80:80"
      - "443:443"

    volumes:
      - /your/directory/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

    environment:
      - TZ=America/Los_Angeles

volumes:
  caddy_data:
  caddy_config:

Configuration Details That Matter

  • No latest image tag
    Caddy behavior can change between releases. Pinning the version prevents unexpected breaks in existing Docker setups.
  • Caddyfile mounted read-only
    The Caddyfile is the source of truth. If the container can overwrite it, configuration control is lost.
  • /data and /config use Docker volumes
    Caddy stores certificates, ACME state, and runtime data in these locations. Volumes ensure persistence across container restarts.

At this stage, ports 80 and 443 are exposed only to verify that Caddy starts correctly. HTTPS is not active yet, and no reverse proxy logic is applied.

Deploying Caddy with Portainer

This setup is designed to run Caddy in Docker using Portainer without relying on Portainer-specific features or UI-generated changes. Portainer is used only as a deployment interface for the Docker Compose stack.

In Portainer:

  1. Open Stacks
  2. Create a new stack
  3. Paste the Docker Compose file
  4. Deploy the stack

All configuration logic lives in Docker Compose and the Caddyfile. Portainer provides visibility and control, not configuration behavior.

The Only Check That Matters

Before configuring HTTPS, reverse proxy rules, or additional services, verify that Caddy is actually serving HTTP traffic. This is the baseline check for any Caddy Docker or Caddy Portainer setup.

Check the container logs:

docker logs caddy

The logs should show that Caddy has started successfully and loaded the configuration without permission or binding errors.

Then test the service directly:

curl http://server-ip

Expected output:

Caddy OK

If Caddy does not respond on port 80, do not continue.
HTTPS, domains, and reverse proxy rules will not fix a broken base configuration.

When It Doesn’t Work: Real Causes

If Caddy is running but does not respond, the problem is usually not Caddy itself. In most Caddy Docker and Caddy Portainer setups, failures come from basic system-level conflicts.

Port 80 or 443 Is Already in Use

This is the most common reason a Caddy reverse proxy in Docker fails to start or accept traffic.

Docker cannot bind a container to ports that are already in use on the host. If another service is listening on port 80 or 443, Caddy will either fail to start or start without serving traffic.

Check which process is using the ports:

ss -tulpn | grep ':80\|:443'

Common conflicts can include:

  • nginx
  • Nginx Proxy Manager
  • Apache
  • another Caddy container
  • leftover test services

If any of these are bound to ports 80 or 443, they must be stopped or removed before Caddy can function correctly.

When to Add Reverse Proxy Rules

Only after HTTP is stable.

Then you can move to something like:

example.com {
    reverse_proxy app:3000
}

Now you’re debugging networking and service names —
not guessing whether the web server is alive.

Minimal Caddyfile Example (Reverse Proxy with Logging)

The following example shows a minimal Caddyfile configuration for running Caddy as a reverse proxy in Docker. Incoming traffic is forwarded to a Ghost container running on port 2368, with compression enabled and access logs written to a file with rotation.

mysite.com {
    reverse_proxy ghost:2368

    encode gzip zstd

    log {
        output file /var/log/caddy/mysite-access.log {
            roll_size 10mb
            roll_keep 10
            roll_keep_for 720h
        }
        format json
    }
}

What This Configuration Does

  • Reverse proxy
    All requests to mysite.com are forwarded to the ghost container on port 2368 over the Docker network.
  • Response compression
    gzip and zstd are enabled to reduce response size and improve performance.
  • Access logging with rotation
    Requests are written in JSON format to a log file with:
    • automatic size-based rotation
    • a fixed number of retained log files
    • optional time-based retention

This setup provides a clean, production-ready base for running Ghost behind Caddy in Docker, with predictable logging and no external dependencies.

When Caddy Is the Wrong Tool

This matters.

  • Want a routing UI → Nginx Proxy Manager
  • Heavy Docker label usage → Traefik
  • Don’t want config files → Caddy will frustrate you

Caddy works best when you want explicit control, not magic.

Final Checklist

If all of this is true, your base is solid:

  • curl http://server-ip returns Caddy OK
  • Ports 80 and 443 are free
  • Caddyfile is mounted read-only
  • /data and /config are Docker volumes
  • Image version is pinned

At that point, Caddy stops being “mysterious”
and becomes predictable.

Read more