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
- A public VPS or homelab accessible from the internet
- A domain name pointing to your server's IP address
- Docker and Docker Compose installed
- Port 80 and 443 open and forwarded to your reverse proxy
- At least one self-hosted application running (Nextcloud, Jellyfin, Vaultwarden, etc.)
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
.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
}
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:
- Automatic HTTPS: Caddy requests a Let's Encrypt certificate on first request and renews it 30 days before expiry.
- Header forwarding: Passes your real IP and protocol to backend apps so they know HTTPS is in use (critical for Nextcloud).
- Security headers: Prevents clickjacking, MIME-sniffing, and enforces HSTS to redirect future HTTP requests to HTTPS.
- Rate limiting: Protects against brute-force attacks on login forms.
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:
- DNS resolution:
nslookup files.example.com - Port forwarding:
curl -I http://YOUR_PUBLIC_IP(on port 80) should reach Caddy - Caddy logs:
docker compose logs caddy | grep -i error
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:
- Limit direct access: Bind backend apps (Nextcloud, Vaultwarden) to localhost only. They should never be exposed directly.
- Enable firewall rules: Use UFW to allow only ports 80, 443, and SSH from your IP.
- Monitor logs: Set up Fail2ban to block IPs attempting to brute-force login endpoints.
- Regular updates: Run
docker compose pull && docker compose up -dweekly to get security patches.
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.