Config for a SPA + Custom Routes

1. Caddy version (caddy version):


2. How I run Caddy:

Ubuntu 18, systemctl managed job as instantiated through apt install caddy. Caddyfile at /etc/caddy/Caddyfile

a. System environment:

See above.

b. Command:

systemctl reload caddy

c. Service/unit/compose file:

Not in use.

d. My complete Caddyfile or JSON config: {
	# Set this path to your site's directory.
	route /couch/* {
 	 uri strip_prefix /couch
	 reverse_proxy localhost:5984

	route /express/* {
	 uri strip_prefix /express
	 reverse_proxy localhost:3000

	try_files {path} {path}/ /index.html # 'redir' of route urls so vue-router can handle them 
	root * /home/skuilder/www

	# Enable the static file server.

        # Another common task is to set up a reverse proxy:
	# reverse_proxy localhost:8080

	# Or serve a PHP site through php-fpm:
	# php_fastcgi localhost:9000
} {
	redir{uri} permanent

3. The problem I’m having:

I’m serving a fat client vue SPA, using couchdb / pouchdb as a persistence layer and a thin express api to do some communication between as necessary.

Per How to serve SPA applications with Caddy v2, I am using try_files in order to redirect routing urls to my index.html so that vue-router can handle them.

The problem is that this SPA configuration seems to have clobbered my reverse proxy routes that serve my couch database and express api.

4. Error messages and/or full log output:

curl -v returns my index.html:

*   Trying
* Connected to ( port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN, server accepted to use h2
* Server certificate:
*  subject:
*  start date: Jun  8 00:45:01 2021 GMT
*  expire date: Sep  6 00:45:01 2021 GMT
*  subjectAltName: host "" matched cert's ""
*  issuer: C=US; O=Let's Encrypt; CN=R3
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x55e596816e10)
> GET /couch HTTP/2
> Host:
> user-agent: curl/7.68.0
> accept: */*
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Connection state changed (MAX_CONCURRENT_STREAMS == 250)!
< HTTP/2 200 
< accept-ranges: bytes
< content-type: text/html; charset=utf-8
< etag: "qubysq12k"
< last-modified: Mon, 07 Jun 2021 12:02:50 GMT
< server: Caddy
< content-length: 1388
< date: Tue, 08 Jun 2021 11:57:25 GMT
<!DOCTYPE html><html><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><link rel="shortcut icon" href=/favicon.ico><title>eduQuilt</title><link href=/css/app.2ede0990.css rel=preload as=style><link href=/css/chunk-vendors.7110519c.css rel=preload as=style><link href=/js/app.2cf22387.js rel=preload as=script><link href=/js/chunk-vendors.6938f9eb.js rel=preload as=script><link href=/css/chunk-vendors.7110519c.css rel=stylesheet><link href=/css/app.2ede0990.css rel=stylesheet><link rel=icon type=image/png sizes=32x32 href=/img/icons/favicon-32x32.png><link rel=icon type=image/png sizes=16x16 href=/img/icons/favicon-16x16.png><link rel=manifest href=/manifest.json><meta name=theme-color content=#4DBA87><meta name=apple-mobile-web-app-capable content=no><meta name=apple-mobile-web-app-status-bar-style content=default><meta name=apple-mobile-web-app-title content=Skuilder><link rel=apple-touch-icon href=/img/icons/apple-touc* Connection #0 to host left intact
h-icon-152x152.png><link rel=mask-icon href=/img/icons/safari-pinned-tab.svg color=#4DBA87><meta name=msapplication-TileImage content=/img/icons/msapplication-icon-144x144.png><meta name=msapplication-TileColor content=#000000></head><body><div id=app></div><script src=/js/chunk-vendors.6938f9eb.js></script><script src=/js/app.2cf22387.js></script></body></html>

whereas it should return the same thing as curl -v localhost:5984 as run on the server:

* Rebuilt URL to: localhost:5984/
*   Trying
* Connected to localhost ( port 5984 (#0)
> GET / HTTP/1.1
> Host: localhost:5984
> User-Agent: curl/7.47.0
> Accept: */*
< HTTP/1.1 200 OK
< Cache-Control: must-revalidate
< Content-Length: 208
< Content-Type: application/json
< Date: Tue, 08 Jun 2021 11:55:54 GMT
< Server: CouchDB/2.3.1 (Erlang OTP/19)
< X-Couch-Request-ID: d67138e15f
< X-CouchDB-Body-Time: 0
{"couchdb":"Welcome","version":"2.3.1","git_sha":"c298091a4","uuid":"1ae74473eae14df82d376643478a02c6","features":["pluggable-storage-engines","scheduler"],"vendor":{"name":"The Apache Software Foundation"}}
* Connection #0 to host localhost left intact

5. What I already tried:

Each of these pieces work in isolation - the route directives successfully forwards requests from my SPA to my DB and API servers, the try_files directive successfully routes requests to my SPA’s index.html. I just can’t get them to work at the same time.

I’ve tried:

  • rearranging the order of these directives in the file
  • searching for a way to limit try_files's matching

6. Links to relevant resources:

You can simplify this by using handle_path instead, which has the strip_prefix built-in:

handle_path /couch/* {
	reverse_proxy localhost:5984

This will run before your route or handle_path blocks, because Caddy sorts directives based on this directive order:

Instead, you can write it like this: {
	handle_path /couch/* {
		reverse_proxy localhost:5984

	handle_path /express/* {
		reverse_proxy localhost:3000

	handle {
		root * /home/skuilder/www

		try_files {path} {path}/ /index.html


Using handle blocks ensures that those parts are handled mutually exclusively (only the first handle or handle_path matched will run) and they are sorted according to the length of their path matcher, so one without a matcher will always run last.


Hey, great! Thanks a bunch.

1 Like