Web request streams passing through a Caddy reverse-proxy relay into a private VPS application while the local admin path stays isolated.
Last edited on August 2, 2026

Quick answer: put Caddy on public ports 80 and 443, keep the application on a loopback address such as 127.0.0.1:3000, and leave Caddy’s administration endpoint local. Check DNS and the upstream first, validate every Caddyfile change, reload instead of restarting, then test both the public route and the application result. This gives one service a clean HTTPS boundary without turning its raw port or Caddy’s control plane into another public interface.

Boundary Expected state Proof before launch
DNS A and any AAAA records reach this VPS Compare dig output with the assigned addresses
Public edge Only required web ports are internet-reachable Test 80/443 externally and review firewall policy
Application Listener is loopback-only for a same-host design ss shows 127.0.0.1:3000, not 0.0.0.0:3000
Caddy admin API Local address or controlled Unix socket Port 2019 is not bound to a public interface
Change process Backup, validate, reload, smoke test Old config is restorable and the live response is verified

What Caddy Changes—and What It Does Not

Caddy can combine reverse proxying, certificate issuance, renewal and HTTP-to-HTTPS redirection in a small configuration. Its Automatic HTTPS documentation explains the important prerequisites: the domain must route to the server, ports 80 and 443 must be reachable, Caddy must be able to bind those ports, and its data directory must remain writable and persistent.

Those defaults reduce certificate work, but they do not repair a stale DNS record, secure an application that still listens publicly, or prove that login, uploads and WebSockets behave correctly. Automatic HTTPS is one layer, not a deployment verdict.

This guide uses the common single-VPS layout: Caddy and the application share a host, while the application accepts connections only from loopback. Multi-server designs need a private network and an explicit transport-security decision; replacing 127.0.0.1 with a public backend address would change the risk model. Because the work joins DNS, service supervision, configuration rollout and observability, it is a DevOps operating workflow rather than a certificate-only tutorial.

Preflight DNS, Ports and Recovery Access

Start from a working Linux baseline and a recovery route. If this is a new machine, complete the first Linux VPS login and access checks before changing firewall rules. Keep the provider console or another tested administration path available while modifying network listeners.

Resolve both address families:

dig +short A app.example.com
dig +short AAAA app.example.com

An unexpected AAAA record matters even when IPv4 is correct. Certificate authorities and users may reach the IPv6 destination, so a stale record can send validation or live traffic to the wrong server. Remove or correct records deliberately; do not keep retrying certificate issuance against known-bad DNS.

Next, confirm that another service is not already occupying the web ports and that the application is healthy locally:

sudo ss -ltnp | grep -E ':(80|443|3000|2019)b'
curl --fail --show-error --silent http://127.0.0.1:3000/healthz

Replace port 3000 and /healthz with the application’s real listener and documented health path. If no health endpoint exists, test a lightweight page or API route whose output you understand. Do not invent a path and then treat its 404 as a proxy failure.

When UFW is already part of the server baseline and SSH recovery has been verified, the public web rules are typically limited to:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status numbered

There is normally no public rule for the application port or Caddy’s admin port. Firewall policy is defense in depth; the more important same-host control is that the application itself binds to loopback.

Install Caddy as a Managed Service

Use the current official Caddy installation instructions for the Linux distribution rather than copying an old repository command from a blog. Package steps and supported releases can change. After installation, use the official systemd service instead of running a privileged foreground process from an SSH shell.

Caddy’s service documentation recommends its systemd unit on Linux because it starts at boot and sends process output to the journal. Verify the installed binary, unit and runtime account:

caddy version
systemctl status caddy --no-pager
systemctl cat caddy
sudo -u caddy test -r /etc/caddy/Caddyfile

The packaged unit is designed to give Caddy the capabilities it needs while the process runs as its dedicated account. Avoid replacing it with a root-owned custom service unless a documented requirement justifies every changed permission.

Certificate state is not disposable cache. Under the official service, Caddy’s default data lives below /var/lib/caddy. Back up the configuration and persistent state according to the current Caddy storage guidance, with file ownership and restore testing preserved.

Build a Minimal Reverse-Proxy Boundary

A first production Caddyfile should be small enough to review:

app.example.com {
    encode zstd gzip
    log
    reverse_proxy 127.0.0.1:3000
}

That site address activates automatic HTTPS for the hostname. The reverse_proxy directive sends the request to the local application, while log enables access logging and encode permits negotiated compression.

The reverse proxy reference says Caddy passes the incoming Host header and manages X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host by default. It ignores untrusted incoming values for those forwarding headers to reduce spoofing. A single direct-to-Caddy setup therefore does not need a copied block of generic proxy headers.

If a CDN or load balancer sits in front, do not trust the entire internet just to recover a client IP. Configure only the provider’s documented address ranges with Caddy’s global trusted_proxies settings, keep those ranges current, and use strict right-to-left parsing when the upstream appends addresses as described by the Caddy documentation. A wrong trust list can turn a user-controlled header into an authentication, rate-limit or audit mistake.

Health checks are also conditional. For multiple upstreams, an application-owned endpoint can help Caddy avoid an unhealthy target:

app.example.com {
    log
    reverse_proxy 127.0.0.1:3000 127.0.0.1:3001 {
        health_uri /healthz
        health_interval 30s
        health_timeout 3s
    }
}

Use that only when both instances really expose a fast, meaningful endpoint. A check that returns 200 before the database or required dependency is ready can create false confidence.

Keep the Administration Endpoint Local

Caddy applies live configuration through an administration API. The official API reference gives its default address as localhost:2019. This endpoint can replace configuration and stop the process, so it belongs on loopback or a permission-controlled Unix socket—not on a public interface.

Check the live listener after Caddy starts:

sudo ss -ltnp | grep ':2019b'

An expected TCP result uses 127.0.0.1:2019, [::1]:2019 or another deliberately local address. Investigate 0.0.0.0:2019, [::]:2019 or a public IP immediately. Also review systemd environment overrides because CADDY_ADMIN can change the default address.

Turning the API off is not automatically safer operationally. Caddy’s global-options documentation warns that admin off makes configuration reloads impossible without stopping and starting the server. For a Caddyfile-managed service, a private control endpoint plus narrow OS permissions usually preserves the safer reload workflow.

Validate, Reload and Retain a Rollback

Treat a Caddyfile edit as a reversible production change. First preserve the known-working file with a unique name:

sudo cp -a /etc/caddy/Caddyfile 
  /etc/caddy/Caddyfile.pre-change-YYYYMMDD-HHMM
sudo caddy fmt --diff /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile

caddy fmt --diff shows formatting changes without overwriting the file. caddy validate loads and provisions the configuration without starting it, catching more than a superficial parse. Validation cannot tell whether the new route produces the right business result, so it is a gate before the live smoke test, not a replacement for it.

Apply a valid change with the service reload path:

sudo systemctl reload caddy
systemctl is-active caddy
sudo journalctl -u caddy --since '-5 minutes' --no-pager

The API documentation states that a config load completes atomically: if the new configuration fails, the old one remains active without downtime. That protects Caddy’s configuration transaction. It does not automatically roll back a syntactically valid route that points at the wrong healthy application.

If the application smoke test regresses, restore the named backup, validate it and reload again:

sudo cp -a /etc/caddy/Caddyfile.pre-change-YYYYMMDD-HHMM 
  /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy

Record which file was restored and why. A pile of unlabeled backups is not a rollback plan.

Prove Each Layer Instead of Testing One URL

One browser refresh can hide cached DNS, a CDN response or an incomplete application path. Test the layers separately:

Layer Test Expected evidence
Upstream curl http://127.0.0.1:3000/healthz Application responds without Caddy
DNS dig A and dig AAAA Every published address belongs to this deployment
TLS edge curl -I https://app.example.com/ Valid HTTPS response and intended redirect behavior
Forced origin curl --resolve app.example.com:443:SERVER_IPV4 -I https://app.example.com/ Correct origin works even when local DNS/cache differs
Admin scope ss check for port 2019 Listener remains local
Application Login, upload, API or WebSocket smoke test Real user path survives proxying
Logs Caddy journal plus application log Same test request is explainable at both layers

Use a placeholder only after replacing it with the actual address:

curl --fail --show-error --silent --output /dev/null 
  --write-out '%{http_code}n' 
  --resolve app.example.com:443:SERVER_IPV4 
  https://app.example.com/

A 502 usually means Caddy reached the route but could not get a valid upstream response. Compare the direct loopback request, application service status and both journals. A certificate error points earlier in the chain: DNS, address-family mismatch, port reachability, rate limiting or certificate storage.

Avoid blindly adding security-header snippets during initial recovery. HSTS, content security policy and cookie behavior can be valuable, but they are application decisions. For example, includeSubDomains is unsafe when another subdomain is not HTTPS-ready, while an overbroad content security policy can break scripts or checkout flows. Establish the proxy path first, then introduce headers with application tests and a rollback.

Operate the Proxy After Launch

Monitor from outside the VPS. A local process check cannot detect broken public DNS, an expired route at an upstream CDN or a provider network failure. The private Uptime Kuma monitoring workflow shows how to keep the monitor independent from the service it watches and prove both outage and recovery alerts.

Retain enough Caddy and application logs to investigate errors without collecting sensitive request data indefinitely. Watch certificate-renewal failures, repeated upstream connection errors, reload failures, storage capacity and unexpected changes to public listeners. Patch Caddy through the supported package path, validate after upgrades and confirm that the service still reads the same configuration and state directories.

For an application with predictable CPU, memory and network needs, an application-ready VPS host provides the compute layer. It does not replace operating ownership: someone still has to maintain the OS, application, Caddy, DNS, backups and incident response.

When This Fits and When It Does Not

Situation Fit Reason
One or several same-host web apps need HTTPS Good Caddy can route hostnames to private loopback listeners
Small team wants a readable configuration and graceful reload Good Caddyfile plus the local API supports a compact change workflow
Existing NGINX deployment is stable and team tooling depends on it Migration decision A proxy swap needs staged testing; novelty alone is not a reason
Backend lives on another public network without private transport Poor as shown Loopback assumptions and trust boundaries no longer apply
Application requires a provider-specific WAF or global edge Incomplete alone Caddy can protect the origin route but is not the external edge service
No one can own updates, DNS, logs or rollback Poor DIY fit Automatic certificates remove tasks, not operational responsibility

The honest limitation is simple: Caddy can make the HTTPS edge and reload process cleaner, but it cannot decide whether a response is semantically correct. A green service status beside a broken login is still an outage.

Teams that do not want to maintain the full boundary should compare the work with managed hosting support, including who owns DNS changes, patch windows, log review, backups and recovery tests.

Recommended Next Step

Choose one non-critical hostname and one loopback-bound application. Verify A and AAAA records, preserve the current proxy configuration, install Caddy from its supported package path, keep port 2019 local, validate the minimal Caddyfile and reload it. Do not expand to more domains until the direct upstream test, public HTTPS route, application smoke test, logs and rollback have all been proven once.

FAQ

Does Caddy open HTTPS automatically for any domain?

No. The hostname must be in the configuration, DNS must point to the server, validation ports must be reachable and Caddy needs persistent writable certificate storage. Wrong DNS or blocked challenges still require operator action.

Should I expose the Caddy admin API for remote automation?

Not directly. Its default is local because it can change configuration and stop the process. Keep it on loopback or a controlled Unix socket, and run remote automation through an authenticated administration path with narrow OS permissions.

Is systemctl restart caddy the right way to apply a Caddyfile change?

Use caddy validate and systemctl reload caddy for a normal Caddyfile-managed service. Reload uses Caddy’s config API and preserves the active configuration if the new one fails, while a restart creates avoidable interruption and recovery risk.

Why does Caddy return 502 Bad Gateway?

Common causes are a stopped application, wrong upstream address, application bound to another interface, container-network mismatch or an upstream TLS expectation. Test the upstream directly and compare Caddy and application logs before changing certificate settings.

Do I need to set proxy forwarding headers manually?

Usually not in a direct-to-Caddy deployment. Caddy manages the main X-Forwarded-* headers by default. Configure trusted proxies only when a known CDN or load balancer sits in front, using its documented address ranges and the appropriate strict parsing behavior.

Leave a Reply

Your email address will not be published. Required fields are marked *