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

  1. Go to Cloudflare Dashboard
  2. “Create Token” โ†’ “Create Custom Token”
  3. Permissions: Zone : DNS : Edit
  4. Zone Resources: Include : Specific zone : your-domain.com

โš ๏ธ Only grant the minimum permissions required!

๐Ÿš€ Deploying an app

Manual method

  1. 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
  1. Start the container:
cd apps/my-app
docker compose up -d
  1. Create the Caddy config:
# sites/my-app.conf
my-app.example.com {
    import security_headers
    reverse_proxy my-app:80
}
  1. Reload Caddy:
docker exec caddy-proxy caddy reload --config /etc/caddy/Caddyfile
  1. 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

  1. Never commit secrets (.env in .gitignore)
  2. Use tokens with minimal permissions
  3. Regularly update Docker images
  4. 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” ๐Ÿฆ„