Back to Blog
devops

How I Replaced Nginx Chaos with One Clean Caddyfile

November 4, 20255 min min read
How I Replaced Nginx Chaos with One Clean Caddyfile

Last week, I was deep in deployment hell. More than 10 apps. 10+ APIs. One overworked developer (me).

Every time I touched Nginx, something broke. Semicolons went missing, SSL expired, or ports collided. All I wanted was for report-api, report.app, and payments-demo.sksushil.info.np to just work.

Then I met Caddy — and my nights got quieter.

The Problem: Too Many Configs, Too Little Sanity

Each microservice meant another Nginx file. Another reload command. Another moment wondering, "Why isn't this proxying?"

And let's be honest: debugging Nginx inside Docker feels like fighting your own reflection.

I needed something that understood Docker — something clean, automatic, and smart.

The "Wait, That's It?" Moment

A friend told me:

Caddy is like Nginx, but with auto HTTPS and no pain.

I tried it. I wrote this one file called Caddyfile:

report-app.sksushil.info.np {
    reverse<em>proxy report-app:80
}
app1.sksushil.info.np {
    reverse</em>proxy app-1:80
}
app1-api.sksushil.info.np {
    reverse<em>proxy app1-api:8080
}
payments-demo.sksushil.info.np {
    reverse</em>proxy payments-app-demo:8080
}

One reload command later, everything was live. SSL worked. Routing worked. I didn't cry.

Note on SSL Conflicts (Cloudflare Users)

Sometimes Caddy's automatic HTTPS can conflict with Cloudflare Flexible SSL, causing redirect loops.

By default, Caddy tries to get a Let's Encrypt certificate. Even if your backend is HTTP, Caddy terminates HTTPS and proxies to your app. This can clash with Cloudflare's SSL and cause infinite redirects.

Solution: Explicitly serve plain HTTP in Caddy:

http://payments-demo.sksushil.info.np {
    reverse<em>proxy payments-app-demo:8080
}

This tells Caddy: "Do not get certificates — just serve HTTP." Now Cloudflare handles SSL entirely.

If you omit http:// or https://, Caddy defaults to HTTPS and tries to issue a certificate automatically.

SPAs in Caddy v2

For single-page apps (SPAs), use rewrite to handle client-side routing:

report-app.sksushil.info.np {
    reverse</em>proxy report-app:80</p><p>    # SPA fallback
    @notFound {
        not file
    }
    rewrite @notFound /index.html
}

If that doesn't work, try the explicit static file matcher approach:

report.com.np {
    reverse<em>proxy report-app:80</p><p>    @static {
        path <em>.js </em>.css <em>.png </em>.jpg <em>.svg </em>.ico <em>.json
    }
    @notStatic {
        not path </em>.js <em>.css </em>.png <em>.jpg </em>.svg <em>.ico </em>.json
    }
    handle @notStatic {
        rewrite * /index.html
    }
}

This ensures your SPA routes work correctly and fall back to index.html when a static file isn't found.

The Secret: Shared Docker Network

Caddy needs to see your app containers — like neighbors on the same street. That's what the shared Docker network does:

docker network create shared-net

Now every app joins this same network and Caddy can reach them by container name.

Caddy Setup

/srv/caddy/docker-compose.yml

services:
  caddy:
    image: caddy:latest
    container</em>name: caddy
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy<em>data:/data
      - caddy</em>config:/config
    networks:
      - shared-net</p><p>volumes:
  caddy<em>data:
  caddy</em>config:</p><p>networks:
  shared-net:
    external: true

Example App Service

/srv/report/docker-compose.yml

services:
  report-app:
    image: report-app:latest
    container_name: report-app
    expose:
      - "80"
    restart: always
    networks:
      - shared-net</p><p>networks:
  shared-net:
    external: true

\ Notice the app uses expose instead of ports. The container is reachable only within the shared Docker network — Caddy is the only public entry point. \

Caddyfile Reload + Format

Once deployed, reload your config anytime without downtime:

docker exec -it caddy caddy reload --config /etc/caddy/Caddyfile

Caddy even formats its own config — just run:

docker exec -it caddy caddy fmt --overwrite /etc/caddy/Caddyfile

The Ending I Wanted

Now my production stack runs three sites, two APIs, and one proxy — all behind a single elegant Caddyfile. No /etc/nginx/sites-enabled, no renewal scripts, no fragile reloads.

Just this:

docker exec -it caddy caddy reload --config /etc/caddy/Caddyfile

…and a peaceful developer sipping coffee while SSL renews itself. ☕

Share this article

Tags

Caddy Nginx Docker DevOps Self-Hosted

Subscribe to Newsletter

Get notified about new articles