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.
- 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
treeClick Run to see what this code prints.
The api/ folder holds the Go service and multi-stage Dockerfile from lesson 11:
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))}# Stage 1: build the program with the full Go toolchainFROM golang:1.27-alpine AS buildWORKDIR /srcCOPY 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 imageFROM scratchCOPY /hello /helloEXPOSE 8080ENTRYPOINT ["/hello"]<!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:
: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 theapicontainer 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
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
proxypublishes a port. The API has noports:section, so it is reachable only from inside the Compose network — by Caddy, using the service nameapi. - The Caddyfile and site are mounted read-only.
- The
caddy-datavolume 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-stoppedbrings both services back after a crash or reboot.
Step 4: Start the Stack
docker compose up -d --buildClick 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 psClick 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"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/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}}"Click Run to see what this code prints.
docker compose logs apiClick 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 apiClick Run to see what this code prints.
curl -s http://localhost:8080/api/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)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.
example.com { encode zstd gzip
handle_path /api/* { reverse_proxy api:8080 }
handle { root * /srv file_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.
Put the steps to take this stack live with HTTPS in order.
- Copy the project to the server
- Run docker compose up -d --build on the server
- Point the domain’s DNS A record at the server
- Open https://your-domain and check the certificate
- Install Docker on the server
- 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.