Reverse proxy is changing URL with a 301 status

1. Caddy version (caddy version):


2. How I run Caddy:

a. System environment:

Ubuntu on DigitalOcean droplet

b. Command:

caddy start

c. Service/unit/compose file:


d. My complete Caddyfile or JSON config: {
	reverse_proxy {
		header_up Host {http.reverse_proxy.upstream.hostport}
		header_up X-Real-IP {http.reverse-proxy.upstream.address}
		header_up X-Forwarded-Port {http.request.port}
		header_up X-Forwarded-Host {}

	log {
		output file /var/log/caddy/access.log

3. The problem I’m having:

I am building a SaaS product where each customer has their own website at a subdomain on my app. The app is built in React and currently hosted on Heroku.

I currently have Caddy deployed as a DigitalOcean droplet. I was hoping to use Caddy as a proxy server to allow my customers to use their own domain names, instead of the subdomain I have on my site. So when someone visits their own domain, it should be serving from my app (from their subdomain) – but the browser should still have their domain in the URL box.

I’ve posted my current Caddyfile, which as far as I can tell should do what I’m looking for. But for some reason, with this setup, when you visit the customer’s website, the browser changes the URL to my app’s address. Digging through the Caddy logs shows that it’s doing a 301 redirect.

Even more confusingly, when I change the reverse proxy URL to the www subdomain, it seems to be working just as I want (no redirect, URL stays the same).

I’m not really sure what to make of this, so any help would be appreciated.

4. Error messages and/or full log output:

5. What I already tried:

Output of curl -I

HTTP/2 301
content-type: text/html
date: Mon, 24 Jan 2022 23:55:25 GMT
server: Caddy
server: nginx
via: 1.1 vegur
content-length: 162

6. Links to relevant resources:

I was working off of these two previous threads:

Remove these lines from your config. They’re not useful. The only ones you should need are Host and X-Forwarded-Host, and there’s a shortcut for those in the docs:

For that, you’re looking for Caddy’s On-Demand TLS feature:

Caddy is not performing the redirect. Your upstream app is. You can tell because you can see Server: nginx in the response headers, which means the response came from your upstream nginx server.

I don’t recommend this approach of proxying the custom domains to your domain. Instead, it would be better to proxy to the actual main app endpoint, then have your app look at the X-Forwarded-Host header, and do a database lookup based on the domain to know which customer it belongs to.


Thank you, thank you – this was SUPER helpful! I’ve taken your suggestions and updated my setup as you described. It seems to be working as expected, which is fantastic.

I also found a helpful blog post that basically implements your suggestion (in case it’s useful for future readers here): How we provision SSL to our SaaS customers with custom domains - Saax

My new Caddyfile is this:

	on_demand_tls {
		interval 2m
		burst 5

:443 {
	reverse_proxy {
		header_up Host {upstream_hostport}
		header_up X-Forwarded-Host {host}

	tls {

	log {
		output file /var/log/caddy/access.log

On-Demand TLS seems like magic! I now just have a couple basic questions around the On-Demand TLS feature I was hoping you could help answer:

  • I’m getting requests to /api/validate_domain with which makes sense. I’m validating those at 200s. I am also getting requests with domain=<PROXY_IP>, where PROXY_IP is the IP of the DigitalOcean droplet that is hosting my Caddy server. Is this expected? Should I whitelist it as well?
  • What is the expected behavior when a non-registered domain tries to use my Caddy proxy to route to my SaaS app? I wasn’t able to find any documentation around this? When I first set this up (before whitelisting, the initial requests to were still making it through fine, but I wasn’t sure if it was due to some local caching on my end.
1 Like

Those are probably bots doing port scanning etc. Ignore these. You don’t want to try to get a cert for a request without a hostname (ACME CAs don’t support issuing certs for IP addresses at this time).

The TLS handshake will fail, and they’ll see an error in their browser. Caddy needs a certificate that matches the domain in the request to successfully complete the handshake, and the browser won’t trust it if the cert was for a different domain etc.

Maybe Caddy already had a certificate for that domain in storage from a previous config? Hard to say.


Makes sense, thanks again for your help.

1 Like

This topic was automatically closed after 30 days. New replies are no longer allowed.