LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
DockerIntermediate~2 hours

Multi-Service Deployment with a Caddy Reverse Proxy

Run a website and an API behind one Caddy reverse proxy with Docker Compose, update one service without touching the other, and prepare it for a real server with automatic HTTPS.

Docker ComposeReverse ProxyCaddyDeployment

Overview

A real server usually runs several things: a website, an API, maybe an admin tool. Only one program can listen on ports 80 and 443, so a reverse proxy sits in front, receives every request and forwards it to the right container. Caddy is a popular choice because it obtains and renews HTTPS certificates automatically.

What You'll Build
  • A Compose stack with two services: Caddy (proxy and static site) and Priya’s Go API.
  • Routing: /api/ goes to the API container; everything else is the website.
  • An API that is not published to the outside world at all — only Caddy can reach it.
  • A zero-fuss update of one service, and the exact steps to go live with HTTPS on a server.

Prerequisites

  • Docker Compose — lesson 10.
  • Multi-stage builds — lesson 11 (the API reuses that Dockerfile).
  • For going live: a Linux server with Docker and a domain name (optional).

Step 1: Project Layout

tree
Output

Click Run to see what this code prints.

The api/ folder holds the Go service and multi-stage Dockerfile from lesson 11:

api/main.go
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname()
fmt.Fprintf(w, "Hello from a tiny Go container (%s)\n", host)
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
api/Dockerfile
# Stage 1: build the program with the full Go toolchain
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /hello .
# Stage 2: copy only the finished program into an empty image
FROM scratch
COPY --from=build /hello /hello
EXPOSE 8080
ENTRYPOINT ["/hello"]
site/index.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Priya's Stack</title></head>
<body>
<h1>Priya's Stack</h1>
<p>Static site served by Caddy. The API lives at /api/.</p>
</body>
</html>

Step 2: The Caddyfile

Caddy’s configuration file is short and readable:

Caddyfile
:80 {
encode zstd gzip
# Requests to /api/... go to the Go service, without the /api prefix
handle_path /api/* {
reverse_proxy api:8080
}
# Everything else is the static website
handle {
root * /srv
file_server
}
}
  • :80 — serve plain HTTP on port 80 for any host name. (On a real server you will replace this with your domain.)
  • encode zstd gzip — compress responses.
  • handle_path /api/* — requests starting with /api/ are forwarded to the api container on port 8080, with the /api prefix removed, so /api/ arrives at the Go program as /.
  • handle — everything else is served as static files from /srv.

Step 3: The Compose File

compose.yaml
services:
proxy:
image: caddy:2-alpine
ports:
- "8080:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./site:/srv:ro
- caddy-data:/data
depends_on:
- api
restart: unless-stopped
api:
build: ./api
restart: unless-stopped
volumes:
caddy-data:
  • Only proxy publishes a port. The API has no ports: section, so it is reachable only from inside the Compose network — by Caddy, using the service name api.
  • The Caddyfile and site are mounted read-only.
  • The caddy-data volume stores Caddy’s certificates, so they survive container updates (important on a real server, where Let’s Encrypt limits how often you can request new ones).
  • restart: unless-stopped brings both services back after a crash or reboot.

Step 4: Start the Stack

docker compose up -d --build
Output

Click Run to see what this code prints.

Compose pulled Caddy, built the API image (named stack-api after the folder), and created a network, the volume and both containers:

docker compose ps
Output

Click Run to see what this code prints.

Step 5: Test the Routing

The website, served by Caddy:

curl -s http://localhost:8080/ | grep "<h1>"
curl -sI http://localhost:8080/ | grep -i "^server"
Output

Click Run to see what this code prints.

And the API through the same port — Caddy forwarded the request to the Go container:

curl -s http://localhost:8080/api/
Output

Click Run to see what this code prints.

Only the proxy is published; the API’s port 8080 is internal:

docker compose ps --format "{{.Service}}: {{.Ports}}"
Output

Click Run to see what this code prints.

docker compose logs api
Output

Click Run to see what this code prints.

Step 6: Update One Service

Priya changes the API’s greeting to “Hello from version 2 of the API”. Naming the service rebuilds and recreates only that container — Caddy and the website keep running:

docker compose up -d --build api
Output

Click Run to see what this code prints.

curl -s http://localhost:8080/api/
Output

Click Run to see what this code prints.

Both containers carry the restart policy from the Compose file:

docker inspect --format "{{.Name}} restart={{.HostConfig.RestartPolicy.Name}}" $(docker compose ps -q)
Output

Click Run to see what this code prints.

Step 7: Going Live on a Server

Locally, everything ran on port 8080 over plain HTTP. On a real Linux server with a domain name, three changes give you automatic HTTPS:

  • Point your domain’s DNS A record at the server’s public IP address.
  • In compose.yaml, publish ports 80 and 443 (and 443/udp for HTTP/3) instead of 8080.
  • In the Caddyfile, replace :80 with your domain name.
Caddyfile (on the server)
example.com {
encode zstd gzip
handle_path /api/* {
reverse_proxy api:8080
}
handle {
root * /srv
file_server
}
}
compose.yaml (proxy ports on the server)
ports:
- "80:80"
- "443:443"
- "443:443/udp"

Copy the project to the server (with git clone, or rsync over SSH), then start it there:

rsync -av --exclude .git stack/ priya@203.0.113.10:~/stack/
ssh priya@203.0.113.10 "cd ~/stack && docker compose up -d --build"

When Caddy starts with a domain name, it requests a certificate from Let’s Encrypt, redirects HTTP to HTTPS, and renews the certificate automatically before it expires. Open ports 80 and 443 in the server’s firewall, and keep SSH open too.

Mini Challenge · Put It in Order

Put the steps to take this stack live with HTTPS in order.

  1. Copy the project to the server
  2. Run docker compose up -d --build on the server
  3. Point the domain’s DNS A record at the server
  4. Open https://your-domain and check the certificate
  5. Install Docker on the server
  6. Set the domain in the Caddyfile and publish ports 80 and 443

Extend This Project

  • Add the Flask + PostgreSQL guestbook from the Compose project as a third service under /guestbook/.
  • Serve a second domain from the same Caddy, each with its own site block.
  • Push the API image to a registry and use image: instead of build: on the server.
  • Automate deployment with a CI/CD pipeline that runs docker compose pull and up on every release.

Summary

You built a small but realistic production layout: one reverse proxy in front, internal services behind it, each updated independently, all described in a single Compose file — and you know the three changes that turn it into an HTTPS site on a real server.