HTTPS for many apps on one server
Follow this on your own server. Start free, no card needed
One VPS is plenty of machine for several small apps: a blog, an internal API, a status page, none of which need a server to themselves. What they do need is each to answer HTTPS on its own subdomain, with a certificate that’s actually valid and actually renews, without you running certbot renew by hand every few months or writing a separate nginx config and cron job per site. A single Caddy instance in front of everything does this with less config than you’d expect, issuing and renewing a certificate per domain from one file.
This guide sets up three apps behind one Caddy instance on one server, each reachable at its own subdomain with HTTPS handled automatically, then covers what Railyard automates about the same problem so there’s no Caddyfile to maintain at all.
What you’ll need
- A VPS with at least 2 GB RAM. A DigitalOcean 2 GB Basic droplet ($12/month) or a Hetzner CX23 (2 vCPU, 4 GB RAM, €5.99/month excluding VAT) has room for several small containers plus Caddy itself.
- A domain you control, with access to add DNS records.
- Docker and the Compose plugin installed on the server.
- Ports 80 and 443 open and not already used by another web server.
Step 1: point DNS at the server before anything else
Caddy requests a certificate the first time each domain is requested over HTTPS, and Let’s Encrypt validates that request by checking the domain actually resolves to this server. Create an A record per subdomain now, so it’s had time to propagate by the time you start Caddy:
blog.example.com. A 203.0.113.10
api.example.com. A 203.0.113.10
status.example.com. A 203.0.113.10
All three can point at the same IP; Caddy tells them apart by the Host header in the request, the same way any reverse proxy does.
Step 2: lay out the apps as separate containers
This example runs three things: a blog app and an API, each its own small container, and a status page served as plain static files with no backend at all. Each app lives in its own directory with its own Dockerfile:
.
├── blog/
│ └── Dockerfile
├── api/
│ └── Dockerfile
├── status/
│ └── index.html
├── docker-compose.yml
└── Caddyfile
Nothing about this depends on what’s actually inside blog or api; a Django app from an earlier guide, a Node API, or anything else that listens on a port in its container works the same way. The only requirement Caddy cares about is that each container exposes one port on the Docker network.
Step 3: write the Caddyfile
{
email ops@example.com
}
blog.example.com {
reverse_proxy blog:8000
}
api.example.com {
reverse_proxy api:8001
}
status.example.com {
root * /srv/status
file_server
}
Each domain is its own block. blog.example.com and api.example.com proxy to their containers by Docker Compose service name; Caddy resolves that name on the internal network the same way it would resolve a hostname anywhere else. status.example.com has no backend at all, Caddy just serves files straight off disk with file_server. The top-level email block is what Let’s Encrypt uses to warn you if a certificate is about to fail to renew; it’s optional but worth setting.
Validate a Caddyfile before trusting it in production:
docker run --rm -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile
You should see Valid configuration printed, not a parse error.
Step 4: write docker-compose.yml
services:
blog:
build: ./blog
restart: unless-stopped
expose:
- "8000"
api:
build: ./api
restart: unless-stopped
expose:
- "8001"
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./status:/srv/status:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- blog
- api
volumes:
caddy_data:
caddy_config:
Only caddy publishes ports 80 and 443 to the host; blog and api use expose, which makes their ports reachable from other containers on the same Compose network but not from outside the server at all. That’s the point of putting everything behind one proxy: the app containers are never directly reachable, only through Caddy, and only on the domain they’re supposed to answer on. The caddy_data volume is what makes certificates survive a restart; losing it means Caddy requests new ones from Let’s Encrypt next time it starts, which is safe but counts against Let’s Encrypt’s rate limits if it happens often.
Step 5: start it and check the certificates
docker compose up -d --build
docker compose logs -f caddy
You should see a line for each domain saying a certificate was obtained, something like certificate obtained successfully with the domain name next to it. Confirm from outside the server:
curl -I https://blog.example.com
curl -I https://api.example.com
curl -I https://status.example.com
Each should return a response with no certificate warning, and curl -v would show a certificate issued by Let’s Encrypt for that exact hostname.
Step 6: add a fourth app later without downtime
Add a new block to the Caddyfile, a new service to docker-compose.yml, create its DNS record, then tell the running Caddy container to pick up the change without restarting anything else:
docker compose up -d newapp
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
caddy reload re-reads the config and only touches what changed; the certificates and connections for the other three domains are undisturbed.
Backups and upgrades
The only state worth backing up here is the caddy_data volume (certificates, which regenerate anyway) and whatever each app itself stores, like a database volume if one of them has one. Back those up the same way you would for any app: a scheduled dump or a volume snapshot copied off the server.
To upgrade Caddy itself, pull the new image tag and recreate just that container:
docker compose pull caddy
docker compose up -d caddy
Existing certificates in caddy_data carry over; Caddy doesn’t need to re-issue anything just because the binary changed version.
The shortcut: do it with Railyard
Everything above (the per-domain certificate, the reverse proxy, the ownership check that keeps someone else from pointing a domain they don’t control at your server) is what Railyard automates per app instead of through one shared Caddyfile. Every app gets https://<name>.<server-ip>.sslip.io with a certificate from the moment it’s deployed, no DNS record needed yet. Adding your own domain means opening the app’s Domains tab, adding the hostname, and creating the A or CNAME record it shows you; a TXT record (_railyard-verify.<domain>) proves you control the domain before the certificate or routing starts, checked automatically every 30 seconds. Running several apps on the same server doesn’t change any of this: each one gets routed and certified independently, the same way the Caddyfile above handles three domains in one config, just without you writing or reloading that config yourself.
For a wildcard domain (*.example.com routing every subdomain to one app) or many subdomains that would otherwise mean one certificate per visit, Networking covers the two options: on-demand TLS (a certificate issued the first time each subdomain is visited, the default) or one certificate for the whole wildcard through a DNS-01 challenge, using a Cloudflare API token or Route 53 access key added under Team settings → DNS credentials.
Troubleshooting
Port 80 or 443 already in use. Something else (often an nginx or Apache installed by the server image, or a leftover from a previous setup) is already bound to it. Find it with ss -tlnp | grep -E ':80|:443' and stop it before starting Caddy.
A certificate never issues for one domain but the others work fine. Check that specific domain’s DNS separately: dig blog.example.com. A typo in one A record is the most common cause, and it only breaks the one domain since Caddy requests certificates independently per site block.
“too many certificates already issued” from Let’s Encrypt. This is a rate limit (currently 5 duplicate certificates per exact domain per week), usually hit by repeatedly tearing down and recreating the caddy_data volume while testing. Keep the volume between restarts, and use Caddy’s internal CA (tls internal in the Caddyfile block) while iterating on config, switching to real certificates once it’s working.
File permission errors on the status page or a mounted volume. file_server needs read access to whatever’s mounted at its root; a volume created by a different container running as a different user is the usual cause. Fix ownership on the host, or mount it :ro as in the example above if nothing needs to write to it.
The server runs out of memory with several containers plus Caddy. Each small app container is usually fine, but check actual usage with docker stats before assuming it’s Caddy; a database container is a more likely culprit than the proxy itself. Size up, or move the heaviest app to its own server and keep pointing its subdomain at wherever it actually runs.
FAQ
Do I need Docker for this, or does it work with a system-installed Caddy? The pattern is identical either way: one Caddyfile with a block per domain, each pointing at a local port instead of a Docker service name. systemd manages the Caddy process instead of Compose.
Do all the apps need to be on the same server? For this guide, yes, since the point is one shared Caddy instance. Nothing stops you from running Caddy on one server and proxying to apps on others over a private network, but at that point you’re building the kind of routing a platform like Railyard already handles.
What happens if I add a hundred subdomains? On-demand TLS issues one certificate per subdomain the first time it’s visited, which works but means a hundred separate Let’s Encrypt certificates to track. A wildcard certificate through DNS-01 covers all of them with one, which is the better choice past a handful of subdomains either in a hand-written Caddyfile or through Railyard’s own DNS credential option.
Does this redirect HTTP to HTTPS automatically? Yes, Caddy does this by default for every domain in the Caddyfile with no extra configuration needed.
Three domains behind one Caddy instance and thirty behind one aren’t meaningfully different setups, the Caddyfile just gets longer. The part worth internalizing is that a reverse proxy issuing its own certificates per domain is simpler to run than a certbot cron job and an nginx config per site, whether you write that Caddyfile yourself or let something else write it for you.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.