Back to Blog
    By Shayne Technology

    Updated

    Deploying Web Apps with Caddy: Docker, HTTPS and Reverse Proxy

    A practical Caddy and Docker Compose walkthrough for static files, SPA routes and a reverse proxy, with local checks and a clear path to HTTPS.
    Deploying Web Apps with Caddy: Docker, HTTPS and Reverse Proxy

    Caddy can serve a built web application and forward API requests from the same address. This walkthrough puts both paths in one small Docker Compose project so you can see which service handled a request before adapting it to a real application.

    The local example uses Caddy 2.11.9 and HTTP on your computer's loopback interface. The final section explains the changes for a public HTTPS domain. The demo backend returns fixed text; it is a routing fixture you can replace with your own API.

    Create a small working project

    With Docker and the Compose plugin installed, create a directory containing compose.yaml, a conf directory and a site directory. Save this as compose.yaml:

    services:
      caddy:
        image: caddy:2.11.9-alpine
        ports:
          - "127.0.0.1:8088:80"
        volumes:
          - ./conf:/etc/caddy:ro
          - ./site:/srv:ro
          - caddy_data:/data
          - caddy_config:/config
      demo:
        image: caddy:2.11.9-alpine
        command: ["caddy", "respond", "--listen", ":9000", "--body", "Demo API is reachable"]
    
    volumes:
      caddy_data:
      caddy_config:
    

    Only Caddy's HTTP listener is published to the host. The demo service is reachable inside the project's network. Docker Compose makes it discoverable as demo, so the proxy can use demo:9000 without a hard-coded container IP. See Docker's Compose networking guide.

    Create conf/Caddyfile:

    :80 {
        encode zstd gzip
    
        handle /api/* {
            reverse_proxy demo:9000
        }
    
        handle /assets/* {
            root * /srv
            file_server
        }
    
        handle {
            root * /srv
            try_files {path} /index.html
            file_server
        }
    }
    

    Create site/index.html:

    <!doctype html>
    <html lang="en">
      <head><meta charset="utf-8"><title>Caddy routing demo</title></head>
      <body><h1>The web application is reachable</h1></body>
    </html>
    

    For a real frontend, place the contents of its build output in site. Keep source files, credentials and private configuration outside that directory.

    Validate, start and check each route

    Run these commands from the project directory:

    docker compose config --quiet
    docker compose run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
    docker compose up -d
    curl -i http://127.0.0.1:8088/
    curl -i http://127.0.0.1:8088/dashboard
    curl -i http://127.0.0.1:8088/api/health
    curl -i http://127.0.0.1:8088/assets/missing.js
    

    The first two requests should return the HTML document with status 200. The API request should return “Demo API is reachable”. The missing asset should return 404. If port 8088 is already occupied, change the host port in Compose and use it in the checks.

    These checks serve different purposes. A homepage response proves static files are available. The dashboard request tests the fallback needed by a client-side router. The API response establishes that the reverse proxy reached another service. The missing-file check catches a common mistake: returning an HTML document when a browser requested JavaScript.

    Understand the routing boundaries

    The API handler forwards the request with its /api/ prefix intact. Your real backend must accept that path, or you need an explicit path-rewriting decision. The separate asset handler lets missing assets remain missing instead of falling through to the application shell. These examples follow Caddy's documented routing patterns.

    The final handler is a broad SPA fallback. It also returns status 200 for unknown page paths. That may be appropriate for a client router, but it does not create genuine HTTP 404 responses or render page-specific content for search engines. For a public content site, decide how prerendered pages and unknown routes will be served.

    Replace demo with your application service when you move beyond the fixture. Inside its container, the application should listen on an interface reachable from the Compose network. Pointing Caddy at localhost would point back at the Caddy container itself. Caddy's reverse-proxy quick-start covers the basic upstream configuration.

    Change configuration without guessing

    After editing the Caddyfile, validate it and ask the running process to reload it:

    docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
    docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
    docker compose logs --tail=50 caddy
    

    The official image runs Caddy in the foreground. You do not need to start a second background process inside it. Configuration validation and reload have distinct jobs; the command-line reference explains both.

    For a useful failure check, stop the demo service with docker compose stop demo. A request to /api/health should fail with 502 while the static homepage remains available. Start the service again with docker compose start demo and repeat the API request. This distinguishes an upstream outage from a frontend routing problem.

    Move to a real HTTPS domain

    On a public host, replace the opening :80 with your hostname, such as app.example.com. Replace the local port mapping with public TCP ports 80 and 443. Configure DNS and the host's network so those ports reach Caddy. UDP 443 is optional if you want to serve HTTP/3.

    Caddy can then obtain and renew a public certificate when its automatic HTTPS requirements are satisfied. A domain name in a file alone does not establish that DNS, firewall rules or an intervening proxy are correct. Check the public HTTP redirect and the certificate presented over HTTPS after deployment.

    Keep the /data volume: it contains certificate state and private keys. Protect and back it up appropriately. Do not publish Caddy's admin endpoint as an internet-facing port. The official running Caddy guide covers service operation and persistent Docker storage.

    What was checked for this revision

    The local fixture was checked with Caddy 2.11.9: configuration validation, static content, the dashboard fallback, API proxying, a missing asset, configuration reload and backend outage/recovery. Public DNS, certificate issuance and your application's own authentication are separate deployment checks.

    When you finish experimenting, docker compose down removes the project's containers and network while retaining the named volumes. The small example is a starting point for routing, not a complete production operating plan.

    If you need help taking an application from a local build to a maintainable service, see my software development and integration work or get in touch.