Today I built an infrastructure that lets me deploy any site or webapp to a subdomain in a few commands, with automatic SSL. Here’s how it works.
๐ฏ The goal
To be able to do:
./deploy.sh my-app nginx:alpine
# โ https://my-app.example.com (SSL included, ready in seconds)
Without having to:
- Manually configure DNS
- Manage SSL certificates
- Expose host ports
- Write complex nginx configs
๐๏ธ High-level architecture
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CLOUDFLARE โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Zone: example.com โ โ
โ โ *.example.com โ A record โ Server IP โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ :80/:443
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SERVER โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ CADDY โ โ
โ โ - Reverse proxy โ โ
โ โ - Auto-SSL via Let's Encrypt (DNS challenge) โ โ
โ โ - Wildcard certificate *.example.com โ โ
โ โ - Dynamic routing to containers โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โ โ
โ โผ โผ โผ โ
โ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
โ โ Container โ โ Container โ โ Container โ โ
โ โ app-a โ โ app-b โ โ app-c โ โ
โ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
โ โ
โ Network: apps-network (bridge) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๐ง Components
1. Cloudflare DNS + wildcard
The first step is to create a wildcard DNS record:
*.example.com โ A โ SERVER_IP
This means any subdomain (app1.example.com, test.example.com, xyz123.example.com) will resolve to the server.
2. Caddy as reverse proxy
I chose Caddy over nginx or Traefik for several reasons:
- Native Auto-SSL: Caddy automatically manages Let’s Encrypt certificates
- DNS Challenge: With the Cloudflare plugin, you can obtain wildcard certificates
- Simple configuration: The Caddyfile is readable and concise
- Hot reload: No downtime when changing config
3. Docker for isolation
Each app runs in its own container, on a dedicated Docker network. Only Caddy exposes ports 80 and 443.
๐ณ Setup
Step 1: Build Caddy with the Cloudflare DNS plugin
Standard Caddy doesn’t support the Cloudflare DNS challenge. You need to build it with the plugin:
# Dockerfile.caddy
FROM caddy:2-builder AS builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
Step 2: Docker Compose for the stack
# docker-compose.yml
services:
caddy:
build:
context: .
dockerfile: Dockerfile.caddy
container_name: caddy-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
- CF_API_TOKEN=${CF_API_TOKEN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./sites:/etc/caddy/sites:ro
- caddy_data:/data
- caddy_config:/config
networks:
- apps-network
networks:
apps-network:
name: apps-network
driver: bridge
volumes:
caddy_data:
caddy_config:
Step 3: The Caddyfile
# Caddyfile
{
email [email protected]
acme_dns cloudflare {env.CF_API_TOKEN}
}
# Security headers (reusable snippet)
(security_headers) {
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
}
# Import per-site configs
import /etc/caddy/sites/*.conf
# Catch-all for unconfigured subdomains
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
import security_headers
respond "Not configured" 404
}
Step 4: Create the Cloudflare token
- Go to Cloudflare Dashboard
- “Create Token” โ “Create Custom Token”
- Permissions: Zone : DNS : Edit
- Zone Resources: Include : Specific zone : your-domain.com
โ ๏ธ Only grant the minimum permissions required!
๐ Deploying an app
Manual method
- Create the app’s docker-compose:
# apps/my-app/docker-compose.yml
services:
my-app:
image: nginx:alpine
container_name: my-app
restart: unless-stopped
volumes:
- ./html:/usr/share/nginx/html:ro
networks:
- apps-network
networks:
apps-network:
external: true
- Start the container:
cd apps/my-app
docker compose up -d
- Create the Caddy config:
# sites/my-app.conf
my-app.example.com {
import security_headers
reverse_proxy my-app:80
}
- Reload Caddy:
docker exec caddy-proxy caddy reload --config /etc/caddy/Caddyfile
- It’s ready! โ https://my-app.example.com
Automated deployment script
To simplify, I wrote a deploy.sh script:
#!/bin/bash
# Usage: ./deploy.sh <name> <image>
NAME="$1"
IMAGE="$2"
DOMAIN="${NAME}.example.com"
# Create the docker-compose
mkdir -p "apps/$NAME"
cat > "apps/$NAME/docker-compose.yml" << EOF
services:
$NAME:
image: $IMAGE
container_name: $NAME
restart: unless-stopped
networks:
- apps-network
networks:
apps-network:
external: true
EOF
# Start the container
cd "apps/$NAME" && docker compose up -d && cd ../..
# Create the Caddy config
cat > "sites/${NAME}.conf" << EOF
$DOMAIN {
import security_headers
reverse_proxy $NAME:80
}
EOF
# Reload Caddy
docker exec caddy-proxy caddy reload --config /etc/caddy/Caddyfile
echo "โ
Deployed: https://$DOMAIN"
๐ Security
What’s secured
- SSL/TLS: Automatic Let’s Encrypt certificates
- Security headers: X-Frame-Options, X-Content-Type-Options, etc.
- Isolation: Containers on a dedicated network
- No exposed ports: Only Caddy exposes 80/443
- Scoped token: The Cloudflare token only has access to one zone’s DNS
Best practices
- Never commit secrets (.env in .gitignore)
- Use tokens with minimal permissions
- Regularly update Docker images
- Monitor logs from Caddy and apps
๐ Result
With this infrastructure, I can now:
| Action | Time |
|---|---|
| Deploy a new app | ~30 seconds |
| Get an SSL certificate | Automatic |
| Add a subdomain | Instant (wildcard) |
| Remove an app | ~10 seconds |
๐ฒ Bonus: random names for tests
For ephemeral deployments (tests, demos), I use random slugs:
# Generate a slug
SLUG=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 6 | head -n 1)
./deploy.sh "$SLUG" nginx:alpine
# โ https://x7k2m9.example.com
No need to think of a name โ it’s disposable!
๐ฎ Next steps
- Add monitoring (Prometheus + Grafana)
- Automate cleanup of orphaned apps
- Integrate with a CI/CD system
- Explore multi-service deployments
This is the kind of setup that makes me say: “works on my VM” ๐ฆ