Serving Static Files with Caddy

1. The problem I’m having:

How can I fix my Caddy configuration to serve demos of my work? I’m trying to serve demos of programming projects with Caddy. I’m learning programming with freeCodeCamp and Codecademy, and I recently started hosting my own Git server using Forgejo. Until now, I used GitHub Pages to deploy demos of my projects, which has meant pushing them to two remotes. This is becoming a pain, so I’d like to use Caddy to serve demos of my projects.
I’d like to serve them at “demos.laniecarmelo.tech/”. I have a Git hook that deploys the projects to a place where Caddy can access them, and this is working well. Projects are located at /var/www/demos/. In my caddy.json file, I’ve set up two routes, one of which redirects “demos.laniecarmelo.tech/” to my portfolio on my WordPress site. Without this route, Caddy complains about no index file. The second rule is supposed to serve projects in /var/www/demos to “demos.laniecarmelo.tech/”. The problem is that everything is being redirected to the portfolio.

2. Error messages and/or full log output:

[lanie@stormux ~] $ curl -vL https://demos.laniecarmelo.tech/
* Host demos.laniecarmelo.tech:443 was resolved.
* IPv6: (none)
* IPv4: 69.58.156.77
*   Trying 69.58.156.77:443...
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: /etc/ssl/certs
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
* 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 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=demos.laniecarmelo.tech
*  start date: Mar  3 18:57:26 2025 GMT
*  expire date: Jun  1 18:57:25 2025 GMT
*  subjectAltName: host "demos.laniecarmelo.tech" matched cert's "demos.laniecarmelo.tech"
*  issuer: C=US; O=Let's Encrypt; CN=E5
*  SSL certificate verify ok.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA384
*   Certificate level 1: Public key type EC/secp384r1 (384/192 Bits/secBits), signed using sha256WithRSAEncryption
*   Certificate level 2: Public key type RSA (4096/152 Bits/secBits), signed using sha256WithRSAEncryption
* Connected to demos.laniecarmelo.tech (69.58.156.77) port 443
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://demos.laniecarmelo.tech/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: demos.laniecarmelo.tech]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.12.1]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: demos.laniecarmelo.tech
> User-Agent: curl/8.12.1
> Accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Request completely sent off
< HTTP/2 404 
< alt-svc: h3=":443"; ma=2592000
< content-security-policy: default-src 'self' https: 'unsafe-inline' 'unsafe-eval'; img-src https: data:; frame-src 'self' https:; object-src 'none'
< referrer-policy: strict-origin-when-cross-origin
< server: Caddy
< strict-transport-security: max-age=31536000; includeSubDomains; preload
< x-content-type-options: nosniff
< x-xss-protection: 1; mode=block
< content-length: 0
< date: Tue, 04 Mar 2025 22:12:04 GMT
< 
* Connection #0 to host demos.laniecarmelo.tech left intact
[lanie@stormux ~] $ curl -vL https://demos.laniecarmelo.tech/dasmotos-arts-and-crafts
* Host demos.laniecarmelo.tech:443 was resolved.
* IPv6: (none)
* IPv4: 69.58.156.77
*   Trying 69.58.156.77:443...
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: /etc/ssl/certs
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
* 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 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=demos.laniecarmelo.tech
*  start date: Mar  3 18:57:26 2025 GMT
*  expire date: Jun  1 18:57:25 2025 GMT
*  subjectAltName: host "demos.laniecarmelo.tech" matched cert's "demos.laniecarmelo.tech"
*  issuer: C=US; O=Let's Encrypt; CN=E5
*  SSL certificate verify ok.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA384
*   Certificate level 1: Public key type EC/secp384r1 (384/192 Bits/secBits), signed using sha256WithRSAEncryption
*   Certificate level 2: Public key type RSA (4096/152 Bits/secBits), signed using sha256WithRSAEncryption
* Connected to demos.laniecarmelo.tech (69.58.156.77) port 443
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://demos.laniecarmelo.tech/dasmotos-arts-and-crafts
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: demos.laniecarmelo.tech]
* [HTTP/2] [1] [:path: /dasmotos-arts-and-crafts]
* [HTTP/2] [1] [user-agent: curl/8.12.1]
* [HTTP/2] [1] [accept: */*]
> GET /dasmotos-arts-and-crafts HTTP/2
> Host: demos.laniecarmelo.tech
> User-Agent: curl/8.12.1
> Accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* Request completely sent off
< HTTP/2 404 
< alt-svc: h3=":443"; ma=2592000
< content-security-policy: default-src 'self' https: 'unsafe-inline' 'unsafe-eval'; img-src https: data:; frame-src 'self' https:; object-src 'none'
< referrer-policy: strict-origin-when-cross-origin
< server: Caddy
< strict-transport-security: max-age=31536000; includeSubDomains; preload
< x-content-type-options: nosniff
< x-xss-protection: 1; mode=block
< content-length: 0
< date: Tue, 04 Mar 2025 22:12:33 GMT
< 
* Connection #0 to host demos.laniecarmelo.tech left intact

3. Caddy version:

v2.9.1 h1:OEYiZ7DbCzAWVb6TNEkjRcSCRGHVoZsJinoDR/n9oaY=

4. How I installed and ran Caddy:

a. System environment:

Stormux, based on Arch Linux ARM. Version: Linux stormux 6.12.17-1-rpi-16k #1 SMP PREEMPT Fri Feb 28 16:02:14 MST 2025 aarch64 GNU/Linux
I built Caddy using Xcaddy and created a Systemd service for it.

b. Command:

sudo systemctl start caddy

c. Service/unit/compose file:

[Unit]
Description=Caddy web server
Documentation=https://caddyserver.com/
After=network.target

[Service]
User=caddy
Group=caddy
WorkingDirectory=/etc/caddy
ExecStart=/usr/bin/caddy run --config /etc/caddy/caddy.json
Restart=on-failure
StartLimitInterval=600

[Install]
WantedBy=multi-user.target

d. My complete Caddy config:

{
  "admin": {
    "listen": "127.0.0.1:2019",
    "origins": ["localhost", "127.0.0.1", "caddy.laniecarmelo.tech"]
  },
  "logging": {
    "logs": {
      "default": {
        "level": "DEBUG"
      }
    }
  },
  "apps": {
    "http": {
      "http_port": 80,
      "https_port": 443,
      "servers": {
        "srv0": {
          "listen": [":443"],
          "routes": [
            {
              "match": [{"host": ["*.laniecarmelo.tech"]}],
              "handle": [
                {
                  "handler": "headers",
                  "response": {
                    "set": {
                      "Content-Security-Policy": [
                        "default-src 'self' https: 'unsafe-inline' 'unsafe-eval'; img-src https: data:; frame-src 'self' https:; object-src 'none'"
                      ],
                      "Referrer-Policy": ["strict-origin-when-cross-origin"],
                      "Strict-Transport-Security": ["max-age=31536000; includeSubDomains; preload"],
                      "X-Content-Type-Options": ["nosniff"],
                      "X-Xss-Protection": ["1; mode=block"]
                    }
                  }
                },
                {
                  "handler": "encode",
                  "encodings": {
                    "br": {},
                    "gzip": {}
                  },
                  "prefer": ["br"]
                }
              ],
              "terminal": false
            },
            {
              "match": [
                {"host": ["demos.laniecarmelo.tech"]},
                {"path": ["/{project}/*"]}
              ],
              "handle": [
                {
                  "handler": "rewrite",
                  "uri": "/{http.vars.project}/{path}"
                },
                {
                  "handler": "file_server",
                  "root": "/var/www/demos/{http.vars.project}",
                  "hide": [".git"],
                  "index_names": ["index.html", "index.htm"]
                }
              ],
              "terminal": true
            },
            {
              "match": [
                {"host": ["demos.laniecarmelo.tech"]},
                {"path": ["/"]}
              ],
              "handle": [
                {
                  "handler": "static_response",
                  "body": "Redirecting to portfolio...",
                  "status_code": 301,
                  "headers": {
                    "Location": ["https://laniecarmelo.tech/accessible-programming-portfolio/"]
                  }
                }
              ],
              "terminal": true
            },
            {
              "match": [{"host": ["home.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3000"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["read.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8000"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["bookmarks.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3009"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["rss.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3010"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["rss-bridge.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3015"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["miniflux-ai.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8083"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["n8n.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:5678"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["files.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3007"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["todo.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3456"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["caddy.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:2019"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["adguard.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3001"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["recipes.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8081"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["dockge.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:5001"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["dozzle.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8080"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["uptime.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3006"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["beszel.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8090"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["code.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3012"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["wiki.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:5000"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["git.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3008"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["irc.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3011"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["search.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8082"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["ai.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3080"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["cockpit.laniecarmelo.tech"]}],
              "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:3004"}]}],
              "terminal": true
            },
            {
              "match": [{"host": ["*.laniecarmelo.tech"]}],
              "handle": [
                {
                  "handler": "static_response",
                  "body": "Not Found",
                  "status_code": 404
                }
              ],
              "terminal": true
            }
          ],
          "logs": {
            "skip_hosts": ["*.laniecarmelo.tech"]
          }
        }
      }
    },
    "tls": {
      "automation": {
        "policies": [
          {
            "subjects": ["*.laniecarmelo.tech"],
            "issuers": [
              {
                "module": "acme",
                "email": "laniecarmelo@gmail.com",
                "challenges": {
                  "dns": {
                    "provider": {
                      "name": "cloudflare",
                      "api_token": "redacted"
                    }
                  }
                }
              }
            ]
          }
        ]
      }
    }
  }
}

I just had to remove the routes for demos.laniecarmelo.tech from my caddy.json because everything, even other domains, was redirecting to the portfolio.

Any specific reason for using the JSON format? It’s typically used by automation-heavy users, where config is updated with scripts and code, or to use advanced features that aren’t available in the Caddyfile.

Anyways, the first issue with your config is this (and all other instances like it):

When you have multiple objects within the array, they’re treated as or condition. I believe you want an and, so you’ll have to put them within a single object in a 1-item array.

The second issue is you’re using {project} here, but there’s no such placeholder. You use {http.vars.project} in the following handler, but you didn’t use the vars handler to begin with which sets the placeholder value. Also, shorthand placeholders, such as {path}, don’t exist in JSON format.

Now for the main issue, do your projects’ directories have index.html files? Double check that after fixing the config.

nitpick: the term “redirect” has a specific meaning in our context, in which the server tells the client, “try at that other URL”; and the client creates a new request/connection to the new URL. The accurate term for your scenario is proxy, in which the server acts as middle-person moving the data back-and-forth between client and upstream.

2 Likes

I actually just switched back to a Caddyfile. At one point I was working with Authelia, trying to set up SSO for my domains, and I think I switched to the JSON file at that time because I thought it might be easier to get things working. I definitely find the Caddyfile easier to work with, though, so I just switched back. Now I’m about to see if I can get Caddy to serve one of my projects.

2 Likes