Documentation

Protect a VPS site with Caddy and Let's Encrypt

Step-by-step guide Updated 4 August 2026

Protect a VPS site with Caddy and Let's Encrypt

Use this when your domain points directly at a VPS and the server can receive ports 80 and 443. Caddy handles the public certificate and renewal; StyloBot sits behind it and protects the application you already run.

What changes: Caddy becomes the public HTTPS endpoint. StyloBot becomes its only upstream. Your app is no longer exposed on a public port.

flowchart LR
    A[Visitor] -->|HTTPS :443| B[Caddy<br/>Let's Encrypt]
    B -->|private Docker network| C[StyloBot]
    C -->|private Docker network| D[Your app]

Before you start

  • Create an A record for your domain pointing to the VPS.
  • Allow inbound TCP 80 and 443 in both the provider firewall and the host firewall. Keep the application and StyloBot ports closed publicly.
  • Complete Add to Docker Compose, or put Caddy, StyloBot, and your application on the same Docker network.

Let's Encrypt must be able to reach port 80 during the initial HTTP challenge. If your provider blocks inbound web traffic or you want the VPS completely private, use Cloudflare Tunnel instead.

1. Add Caddy as the only public service

Add this to the same compose.yml as stylobot. Do not publish app or stylobot ports.

services:
  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - stylobot
    restart: unless-stopped

volumes:
  caddy-data:
  caddy-config:

Keep the caddy-data volume: it stores certificate and renewal state.

2. Point Caddy at StyloBot

Create Caddyfile next to compose.yml and replace the domain:

example.com {
    reverse_proxy stylobot:8080 {
        # Preserve browser-to-Caddy details for redirects and detection.
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
        header_up Sb-Http-Version {http.request.proto}
        header_up X-Client-TLS-Version {http.request.tls.version}
        header_up X-Client-TLS-Cipher {http.request.tls.cipher_suite}
    }
}

Caddy automatically obtains the certificate, redirects HTTP to HTTPS, and renews the certificate before expiry. The forwarded headers are important: without them the gateway can only see the internal Caddy-to-StyloBot connection, not the browser's original protocol and TLS details.

3. Start and verify

docker compose up -d
docker compose logs -f caddy
curl -I http://example.com
curl -I https://example.com

The HTTP request should redirect to HTTPS. The HTTPS request should load your app. Open the StyloBot dashboard and make a fresh request; its signature should show the client protocol and TLS version rather than a blank value.

The three failures people hit first

What you see Likely cause Fix
Caddy cannot issue a certificate DNS or port 80 does not reach the VPS Confirm the domain resolves to this server and no other process owns 80/443.
Endless HTTPS redirects The app sees only its internal HTTP hop Make the app trust X-Forwarded-Proto; do not add a second TLS proxy.
The site works, but dashboard IP/TLS details are wrong Proxy headers are missing or the app is still reachable directly Keep the header_up lines and remove the app's public ports: mapping.

When Cloudflare is already in front

Do not create competing TLS paths. Either use Caddy as the direct public endpoint with DNS-only records, or use Cloudflare Tunnel and let Cloudflare own public HTTPS. For the full client-signal mapping behind any proxy, see reverse-proxy signal forwarding.

Next: choose an enforcement policy after you have watched real traffic in observe-only mode.