Securing Your Self-Hosted Applications with a Reverse Proxy and SSL Certificates

Securing Your Self-Hosted Applications with a Reverse Proxy and SSL Certificates

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

When I first started self-hosting, I exposed my apps directly to the internet on random ports—a terrible decision I don't recommend. Today, I run everything behind Caddy, a reverse proxy that handles SSL termination, routing, and security automatically. This setup gives me HTTPS, rate limiting, and automatic certificate renewal without touching Let's Encrypt APIs manually.

If you're running self-hosted applications on a VPS or homelab, a reverse proxy with SSL is non-negotiable. Here's exactly how I set it up, and why it matters.

Why You Need a Reverse Proxy and SSL

A reverse proxy sits between your users and your applications. It terminates incoming connections, applies security policies, routes traffic to the correct internal service, and handles HTTPS encryption. Without one, you either expose app ports directly (insecure and complex) or manage certificates per application (a maintenance nightmare).

SSL/TLS encryption protects credentials in transit. When you self-host Nextcloud, Vaultwarden, or any password-protected service, unencrypted HTTP is a liability. Modern browsers block HTTP forms, and you'll lose trust from users. Plus, if you're running on a public VPS—even a cheap RackNerd instance at around $40/year—attackers scan for unencrypted services constantly.

A reverse proxy consolidates all this into one place: one certificate, one TLS policy, one rate limiter protecting all your apps.

Caddy vs. Nginx vs. Traefik: Why I Chose Caddy

I prefer Caddy because it requires zero manual certificate management. It requests and renews Let's Encrypt certificates automatically, with no cron jobs or renewal scripts. Its configuration is readable—a 20-line Caddyfile beats 200 lines of Nginx blocks. For small deployments (homelab, single VPS), this simplicity matters.

Nginx is powerful and fast, but you'll manage certificates manually (or script them with Certbot). Traefik is excellent for Docker-heavy setups with dozens of containers, but adds operational overhead for small labs.

For this tutorial, I'll use Caddy in Docker. If you have a smaller lab or prefer bare metal, you can install Caddy directly on Ubuntu via sudo apt install caddy.

Prerequisites

Setting Up Caddy with Docker Compose

Here's my production-ready Docker Compose setup. I run Caddy alongside my other services, using Docker's internal DNS to route to backend apps on a shared network.

version: '3.8'

services:
  caddy:
    image: caddy:2.7-alpine
    container_name: caddy-reverse-proxy
    restart: always
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    environment:
      - ACME_AGREE=true
    networks:
      - homelab
    depends_on:
      - nextcloud
      - vaultwarden

  nextcloud:
    image: nextcloud:latest
    container_name: nextcloud
    restart: always
    volumes:
      - nextcloud_data:/var/www/html
    environment:
      - MYSQL_HOST=db
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_PASSWORD=your_secure_password_here
      - NEXTCLOUD_ADMIN_USER=admin
      - NEXTCLOUD_ADMIN_PASSWORD=your_admin_password_here
      - NEXTCLOUD_TRUSTED_DOMAINS=files.example.com
    networks:
      - homelab
    depends_on:
      - db

  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: always
    volumes:
      - vaultwarden_data:/data
    environment:
      - DOMAIN=https://vault.example.com
      - SIGNUPS_ALLOWED=false
      - INVITATIONS_ALLOWED=true
    networks:
      - homelab

  db:
    image: mariadb:latest
    container_name: nextcloud_db
    restart: always
    volumes:
      - db_data:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=your_root_password_here
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_PASSWORD=your_secure_password_here
    networks:
      - homelab

volumes:
  caddy_data:
  caddy_config:
  nextcloud_data:
  vaultwarden_data:
  db_data:

networks:
  homelab:
    driver: bridge
Tip: Store passwords in a .env file instead of hardcoding them in docker-compose.yml. Use docker compose config to verify interpolation before deploying.

Creating Your Caddyfile

The Caddyfile is where the magic happens. Here's my configuration for three services with automatic HTTPS and security headers:

# Global options
{
  email [email protected]
  on_demand_tls {
    ask http://localhost:2019/internal/on-demand-tls
  }
}

# Files service (Nextcloud)
files.example.com {
  reverse_proxy nextcloud:80 {
    header_up Host {host}
    header_up X-Real-IP {remote_host}
    header_up X-Forwarded-For {remote_host}
    header_up X-Forwarded-Proto https
  }
  
  # Security headers
  header Strict-Transport-Security "max-age=31536000; includeSubDomains"
  header X-Content-Type-Options "nosniff"
  header X-Frame-Options "SAMEORIGIN"
  header Referrer-Policy "strict-origin-when-cross-origin"
  
  # Rate limiting: 100 requests per minute per IP
  rate_limit * 100 30s
}

# Password manager (Vaultwarden)
vault.example.com {
  reverse_proxy vaultwarden:80 {
    header_up Host {host}
    header_up X-Real-IP {remote_host}
    header_up X-Forwarded-For {remote_host}
    header_up X-Forwarded-Proto https
  }
  
  header Strict-Transport-Security "max-age=31536000; includeSubDomains"
  header X-Content-Type-Options "nosniff"
  header X-Frame-Options "SAMEORIGIN"
  
  rate_limit * 50 30s
}

# Catch-all for other services
*.example.com {
  abort 403
}

# ACME challenge endpoints (required for Let's Encrypt)
http://example.com {
  root * /var/www/certbot
  file_server
}
Watch out: Replace files.example.com, vault.example.com, and example.com with your actual domain. Caddy will fail to start if your domain doesn't resolve or isn't publicly accessible. Test DNS resolution: nslookup files.example.com.

This configuration does several things:

Deploying and Testing

Create a directory structure for your deployment:

mkdir -p caddy-setup && cd caddy-setup
cat > docker-compose.yml << 'EOF'
# Paste the docker-compose.yml from above
EOF

cat > Caddyfile << 'EOF'
# Paste the Caddyfile from above
EOF

# Create a .env file for sensitive values
cat > .env << 'EOF'
MYSQL_ROOT_PASSWORD=your_root_password_here
MYSQL_PASSWORD=your_secure_password_here
NEXTCLOUD_ADMIN_PASSWORD=your_admin_password_here
EOF

# Start services
docker compose up -d

# Check Caddy logs for certificate issuance
docker compose logs -f caddy

You should see output like:

caddy-reverse-proxy  | {"level":"info","ts":1711638000.123,"msg":"autosave","duration":0.234}
caddy-reverse-proxy  | {"level":"info","ts":1711638010.456,"msg":"certificate obtained successfully","subjects":["files.example.com"]}
caddy-reverse-proxy  | {"level":"info","ts":1711638015.789,"msg":"Caddy started successfully"}

Once it starts, visit your domain in a browser. You should see a green padlock and no warnings. If you see a certificate error, check:

Hardening: Add Authentication with Authelia

For publicly accessible services, I add an authentication layer using Authelia. This forces all users to log in, even if they find the app URL.

Add this to your docker-compose.yml:

  authelia:
    image: authelia/authelia:latest
    container_name: authelia
    restart: always
    volumes:
      - ./authelia-config.yml:/config/configuration.yml:ro
      - authelia_data:/var/lib/authelia
    environment:
      - AUTHELIA_JWT_SECRET=your_jwt_secret_here_min_32_chars
      - AUTHELIA_SESSION_SECRET=your_session_secret_min_32_chars
      - AUTHELIA_STORAGE_ENCRYPTION_KEY=your_encryption_key_min_32_chars
    networks:
      - homelab
    ports:
      - "9091:9091"

Then protect Nextcloud in your Caddyfile:

files.example.com {
  forward_auth authelia:9091 {
    uri /api/verify?rd=https://auth.example.com
    copy_headers Remote-User Remote-Groups
  }
  
  reverse_proxy nextcloud:80 {
    header_up Remote-User {http.auth.user}
  }
}

Monitoring and Renewal

Caddy's certificate management is automatic. To verify renewal is working:

# Check certificate details
docker exec caddy-reverse-proxy caddy list-certs

# View certificate expiry in human-readable format
docker exec caddy-reverse-proxy sh -c 'openssl x509 -in /data/caddy/certificates/acme/acme-v02.api.letsencrypt.org*/files.example.com*.crt -noout -dates'

Caddy checks for renewals every 12 hours and renews 30 days before expiry. If renewal fails, you'll see errors in the logs. Most failures are DNS or connectivity issues, not Caddy itself.

Next Steps: Protecting Your Backend Applications

A reverse proxy is the first layer of defense, but don't stop there:

This setup scales to 10 or 100 services behind one reverse proxy. Once it's running, you'll wonder why you ever exposed app ports directly.