diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 50501a0f1..3e568d38f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -63,6 +63,7 @@ jobs:
contents: read
pull-requests: read
actions: write # to allow uploading artifacts and cache
+ checks: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@@ -152,6 +153,111 @@ jobs:
# echo "step_test ${{ steps.step_test.outputs.status }}\n"
# exit 1
+ spec-test:
+ permissions:
+ checks: write
+ pull-requests: write
+ strategy:
+ matrix:
+ os:
+ - linux
+ go:
+ - '1.25'
+
+ include:
+ # Set the minimum Go patch version for the given Go minor
+ # Usable via ${{ matrix.GO_SEMVER }}
+ - go: '1.25'
+ GO_SEMVER: '~1.25.0'
+
+ # Set some variables per OS, usable via ${{ matrix.VAR }}
+ # OS_LABEL: the VM label from GitHub Actions (see https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories)
+ # CADDY_BIN_PATH: the path to the compiled Caddy binary, for artifact publishing
+ # SUCCESS: the typical value for $? per OS (Windows/pwsh returns 'True')
+ - os: linux
+ OS_LABEL: ubuntu-latest
+ CADDY_BIN_PATH: ./cmd/caddy/caddy
+ SUCCESS: 0
+
+ runs-on: ${{ matrix.OS_LABEL }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Install Go
+ uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
+ with:
+ go-version: ${{ matrix.GO_SEMVER }}
+ check-latest: true
+
+ - name: Print Go version and environment
+ id: vars
+ shell: bash
+ run: |
+ printf "curl version: $(curl --version)\n"
+ printf "Using go at: $(which go)\n"
+ printf "Go version: $(go version)\n"
+ printf "\n\nGo environment:\n\n"
+ go env
+ printf "\n\nSystem environment:\n\n"
+ env
+ printf "Git version: $(git version)\n\n"
+ # Calculate the short SHA1 hash of the git commit
+ echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+
+ - name: Get dependencies
+ run: |
+ go get -v -t ./...
+ # mkdir test-results
+ - name: Build Caddy
+ working-directory: ./cmd/caddy
+ env:
+ CGO_ENABLED: 0
+ run: |
+ go build -cover -tags nobadger,nopgx,nomysql -trimpath -ldflags="-w -s" -v
+
+ - name: Install Hurl
+ env:
+ HURL_VERSION: "7.0.0"
+ run: |
+ curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/${HURL_VERSION}/hurl_${HURL_VERSION}_amd64.deb
+ sudo dpkg -i hurl_${HURL_VERSION}_amd64.deb
+ hurl --version
+
+ - name: Run Caddy
+ run: |
+ ./cmd/caddy/caddy environ
+ mkdir coverdir
+ export GOCOVERDIR=./coverdir
+ ./cmd/caddy/caddy start
+ sleep 5
+
+ - name: Run tests with Hurl
+ run: |
+ mkdir hurl-report
+ find . -name *.hurl -exec hurl --jobs 1 --variables-file caddytest/spec/hurl_vars.properties --very-verbose --verbose --test --report-junit hurl-report/junit.xml --color {} \;
+
+ - name: Publish Test Results
+ uses: EnricoMi/publish-unit-test-result-action@3a74b2957438d0b6e2e61d67b05318aa25c9e6c6 # v2.20.0
+ with:
+ files: |
+ hurl-report/junit.xml
+
+ - name: Generate Coverage Data
+ run: |
+ export GOCOVERDIR=./coverdir
+ ./cmd/caddy/caddy stop
+ go tool covdata textfmt -i=coverdir -o hurl-report/caddy_cover_${{ steps.vars.outputs.short_sha }}.txt
+ go tool cover -html hurl-report/caddy_cover_${{ steps.vars.outputs.short_sha }}.txt -o hurl-report/caddy_cover_${{ steps.vars.outputs.short_sha }}.html
+
+
+ - name: Publish Coverage Profile
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ path: hurl-report/caddy_cover_${{ steps.vars.outputs.short_sha }}.html
+ compression-level: 0
+
s390x-test:
name: test (s390x on IBM Z)
permissions:
diff --git a/caddytest/spec/http/basicauth/spec.hurl b/caddytest/spec/http/basicauth/spec.hurl
new file mode 100644
index 000000000..0362d3dbd
--- /dev/null
+++ b/caddytest/spec/http/basicauth/spec.hurl
@@ -0,0 +1,38 @@
+# Configure Caddy
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+ debug
+}
+localhost {
+ log
+ basic_auth {
+ john $2a$14$x4HlYwA9Zeer4RkMEYbUzug9XxWmncneR.dcMs.UjalR95URnHg5.
+ }
+ respond "Hello, World!"
+}
+```
+
+# requests without `Authorization` header are rejected with 401
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 401
+[Asserts]
+header "WWW-Authenticate" == "Basic realm=\"restricted\""
+
+
+# requests with `Authorization` header are accepted with 200
+GET https://localhost:9443
+[BasicAuth]
+john:password
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+`Hello, World!`
diff --git a/caddytest/spec/http/error/spec.hurl b/caddytest/spec/http/error/spec.hurl
new file mode 100644
index 000000000..8e2798da6
--- /dev/null
+++ b/caddytest/spec/http/error/spec.hurl
@@ -0,0 +1,150 @@
+# Configure Caddy with error directive
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ error /forbidden* "Access denied" 403
+ respond "OK"
+}
+```
+
+# error directive triggers 403 for matching paths
+GET https://localhost:9443/forbidden/resource
+[Options]
+insecure: true
+HTTP 403
+
+
+# error directive does not trigger for non-matching paths
+GET https://localhost:9443/allowed
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "OK"
+
+
+# Configure Caddy with error and handle_errors
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ error /admin* "Forbidden" 403
+ handle_errors {
+ respond "Custom error: {err.status_code} - {err.status_text}"
+ }
+}
+```
+
+# error with handle_errors shows custom error page
+GET https://localhost:9443/admin/panel
+[Options]
+insecure: true
+HTTP 403
+[Asserts]
+body == "Custom error: 403 - Forbidden"
+
+
+# Configure Caddy with conditional error
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ @admin path /admin*
+ error @admin 404
+ respond "Public content"
+}
+```
+
+# error with named matcher triggers on match
+GET https://localhost:9443/admin/users
+[Options]
+insecure: true
+HTTP 404
+
+
+# error with named matcher doesn't trigger on non-match
+GET https://localhost:9443/public
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "Public content"
+
+
+# Configure Caddy with error for specific methods
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ @post method POST
+ error @post "Method not allowed" 405
+ respond "GET OK"
+}
+```
+
+# error blocks POST requests
+POST https://localhost:9443
+[Options]
+insecure: true
+HTTP 405
+
+
+# error allows GET requests
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "GET OK"
+
+
+# Configure Caddy with dynamic error message
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ error /error* "Path {path} not found" 404
+ handle_errors {
+ respond "{err.message}"
+ }
+}
+```
+
+# error message can use placeholders
+GET https://localhost:9443/error/test
+[Options]
+insecure: true
+HTTP 404
+[Asserts]
+body == "Path /error/test not found"
diff --git a/caddytest/spec/http/file_server/assets/indexed/index.html b/caddytest/spec/http/file_server/assets/indexed/index.html
new file mode 100644
index 000000000..da5de44a0
--- /dev/null
+++ b/caddytest/spec/http/file_server/assets/indexed/index.html
@@ -0,0 +1,9 @@
+
+
+
+ Index.html Title
+
+
+ Index.html
+
+
\ No newline at end of file
diff --git a/caddytest/spec/http/file_server/assets/indexed/index.txt b/caddytest/spec/http/file_server/assets/indexed/index.txt
new file mode 100644
index 000000000..21fc0c67e
--- /dev/null
+++ b/caddytest/spec/http/file_server/assets/indexed/index.txt
@@ -0,0 +1 @@
+index.txt
\ No newline at end of file
diff --git a/caddytest/spec/http/file_server/assets/unindexed/.gitkeep b/caddytest/spec/http/file_server/assets/unindexed/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/caddytest/spec/http/file_server/spec.hurl b/caddytest/spec/http/file_server/spec.hurl
new file mode 100644
index 000000000..be9504e15
--- /dev/null
+++ b/caddytest/spec/http/file_server/spec.hurl
@@ -0,0 +1,119 @@
+# Configure Caddy with default configuration
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+ debug
+}
+localhost {
+ root {{indexed_root}}
+ file_server
+}
+```
+
+# requests without specific file receive index file per
+# the default index list: index.html, index.txt
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+```
+
+
+
+ Index.html Title
+
+
+ Index.html
+
+```
+
+
+# if index.txt is specifically requested, we expect index.txt
+GET https://localhost:9443/index.txt
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "index.txt"
+
+# requests for sub-folder followed by .. result in sanitized path
+GET https://localhost:9443/non-existent/../index.txt
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "index.txt"
+
+# results out of root folder are sanitized,
+# and conform to default index list sequence.
+GET https://localhost:9443/../
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+```
+
+
+
+ Index.html Title
+
+
+ Index.html
+
+```
+
+
+# Configure Caddy with custsom index "index.txt"
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+ debug
+}
+localhost {
+ root {{indexed_root}}
+ file_server {
+ index index.txt
+ }
+}
+```
+
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "index.txt"
+
+
+# Configure with a root not containing index files
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+ debug
+}
+localhost {
+ root {{unindexed_root}}
+ file_server
+}
+```
+
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 404
\ No newline at end of file
diff --git a/caddytest/spec/http/forward_auth/spec.hurl b/caddytest/spec/http/forward_auth/spec.hurl
new file mode 100644
index 000000000..e863c9e9a
--- /dev/null
+++ b/caddytest/spec/http/forward_auth/spec.hurl
@@ -0,0 +1,132 @@
+# Configure Caddy with forward_auth directive
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ forward_auth localhost:9080 {
+ uri /auth
+ }
+ respond "Protected content"
+}
+http://localhost:9080 {
+ handle /auth {
+ respond 200
+ }
+}
+```
+
+# forward_auth allows request when auth endpoint returns 2xx
+GET https://localhost:9443
+[Options]
+delay: 500ms
+insecure: true
+HTTP 200
+[Asserts]
+body == "Protected content"
+
+
+# Configure Caddy with forward_auth rejecting
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ forward_auth localhost:9080 {
+ uri /auth
+ }
+ respond "Protected content"
+}
+http://localhost:9080 {
+ handle /auth {
+ respond 401
+ }
+}
+```
+
+# forward_auth blocks request when auth endpoint returns 4xx
+GET https://localhost:9443
+[Options]
+delay: 500ms
+insecure: true
+HTTP 401
+
+
+# Configure Caddy with forward_auth copying headers
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ forward_auth localhost:9080 {
+ uri /auth
+ copy_headers X-User-ID X-User-Email
+ }
+ respond "User: {header.X-User-ID}, Email: {header.X-User-Email}"
+}
+http://localhost:9080 {
+ handle /auth {
+ header X-User-ID "user123"
+ header X-User-Email "user@example.com"
+ respond 200
+ }
+}
+```
+
+# forward_auth copies specified headers from auth response
+GET https://localhost:9443
+[Options]
+delay: 500ms
+insecure: true
+HTTP 200
+[Asserts]
+body == "User: user123, Email: user@example.com"
+
+
+# Configure Caddy with forward_auth and custom headers
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ forward_auth localhost:9080 {
+ uri /auth
+ header_up X-Original-URL {uri}
+ }
+ respond "OK"
+}
+http://localhost:9080 {
+ handle /auth {
+ respond "{header.X-Original-URL}"
+ }
+}
+```
+
+# forward_auth can send custom headers to auth endpoint
+GET https://localhost:9443/test/path
+[Options]
+delay: 500ms
+insecure: true
+HTTP 200
+[Asserts]
+body == "OK"
diff --git a/caddytest/spec/http/headers/spec.hurl b/caddytest/spec/http/headers/spec.hurl
new file mode 100644
index 000000000..909402b6f
--- /dev/null
+++ b/caddytest/spec/http/headers/spec.hurl
@@ -0,0 +1,22 @@
+# Configure Caddy
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+ debug
+}
+localhost {
+ header "X-Custom-Header" "Custom-Value"
+}
+```
+
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+header "X-Custom-Header" == "Custom-Value"
diff --git a/caddytest/spec/http/request_header/spec.hurl b/caddytest/spec/http/request_header/spec.hurl
new file mode 100644
index 000000000..44e8c3d06
--- /dev/null
+++ b/caddytest/spec/http/request_header/spec.hurl
@@ -0,0 +1,190 @@
+# Configure Caddy with request_header directive
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ request_header X-Custom-Header "CustomValue"
+ respond "{header.X-Custom-Header}"
+}
+```
+
+# request_header adds headers to request
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "CustomValue"
+
+
+# Configure Caddy with request_header removing headers
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ request_header -User-Agent
+ respond "UA: {header.User-Agent}"
+}
+```
+
+# request_header can remove headers
+GET https://localhost:9443
+User-Agent: TestAgent/1.0
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "UA: "
+
+
+# Configure Caddy with request_header replacing headers
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ request_header Host "example.com"
+ respond "Host: {host}"
+}
+```
+
+# request_header can replace Host header
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "Host: example.com"
+
+
+# Configure Caddy with request_header using placeholders
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ request_header X-Original-Path {path}
+ respond "Path: {header.X-Original-Path}"
+}
+```
+
+# request_header can use placeholders
+GET https://localhost:9443/test/path
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "Path: /test/path"
+
+
+# Configure Caddy with conditional request_header
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ @api path /api/*
+ request_header @api X-API "true"
+ respond "API: {header.X-API}"
+}
+```
+
+# request_header applies conditionally based on matcher
+GET https://localhost:9443/api/test
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "API: true"
+
+
+# request_header doesn't apply when matcher doesn't match
+GET https://localhost:9443/other
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "API: "
+
+
+# Configure Caddy with multiple request_header operations
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ request_header X-First "1"
+ request_header X-Second "2"
+ request_header X-Third "3"
+ respond "{header.X-First},{header.X-Second},{header.X-Third}"
+}
+```
+
+# multiple request_header directives are applied
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "1,2,3"
+
+
+# Configure Caddy with request_header and reverse_proxy
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+ debug
+}
+localhost {
+ request_header X-Custom-Header "Value"
+ reverse_proxy localhost:9450
+}
+http://localhost:9450 {
+ respond "{header.X-Custom-Header}"
+}
+```
+
+# request_header adds header before reverse_proxy
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "Value"
diff --git a/caddytest/spec/http/requestbody/spec.hurl b/caddytest/spec/http/requestbody/spec.hurl
new file mode 100644
index 000000000..3abf56e96
--- /dev/null
+++ b/caddytest/spec/http/requestbody/spec.hurl
@@ -0,0 +1,36 @@
+# Configure Caddy
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ log
+ request_body {
+ max_size 2B
+ }
+ reverse_proxy localhost:8000 # to fake body reading
+ handle_errors 4xx {
+ respond "OK"
+ }
+}
+http://localhost:8000 {
+ respond "Failed"
+}
+```
+
+GET https://localhost:9443
+[Options]
+delay: 1s
+insecure: true
+```
+Hello
+```
+HTTP 413
+`OK`
+
+# TODO: how to test{read,write}_timeout?
\ No newline at end of file
diff --git a/caddytest/spec/http/rewrite/spec.hurl b/caddytest/spec/http/rewrite/spec.hurl
new file mode 100644
index 000000000..dd1de7bdc
--- /dev/null
+++ b/caddytest/spec/http/rewrite/spec.hurl
@@ -0,0 +1,66 @@
+# Configure Caddy
+POST http://localhost:2019/load
+User-Agent: hurl/ci
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ rewrite /from /to
+ respond {uri}
+}
+```
+
+# simple scenario: rewriting /from to /to produces expected result of seeing /to
+GET https://localhost:9443/from
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/to"
+
+# unmatched path is passed through unchanged
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/"
+
+# having a query parameter does not trip the rewrite and retains the query
+GET https://localhost:9443/from?query_param=value
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/to?query_param=value"
+
+
+# Configure Caddy
+POST http://localhost:2019/load
+User-Agent: hurl/ci
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ rewrite /from /to?a=b
+ respond {uri}
+}
+```
+
+# a rewrite with query parameters affects the parameters
+GET https://localhost:9443/from?query_param=value
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/to?a=b"
diff --git a/caddytest/spec/http/route/spec.hurl b/caddytest/spec/http/route/spec.hurl
new file mode 100644
index 000000000..7d6ba8706
--- /dev/null
+++ b/caddytest/spec/http/route/spec.hurl
@@ -0,0 +1,171 @@
+# Configure Caddy with route directive
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ route /api/* {
+ uri strip_prefix /api
+ respond "API: {uri}"
+ }
+ respond "Not API"
+}
+```
+
+# route groups handlers and maintains order
+GET https://localhost:9443/api/users
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "API: /users"
+
+
+# route doesn't match non-matching paths
+GET https://localhost:9443/other
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "Not API"
+
+
+# Configure Caddy with nested routes
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ route /api/* {
+ uri strip_prefix /api
+ route /v1/* {
+ uri strip_prefix /v1
+ respond "API v1: {uri}"
+ }
+ respond "API: {uri}"
+ }
+}
+```
+
+# nested routes process sequentially
+GET https://localhost:9443/api/v1/users
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "API v1: /users"
+
+
+# outer route processes when inner doesn't match
+GET https://localhost:9443/api/users
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "API: /users"
+
+
+# Configure Caddy with route and terminal handlers
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ route {
+ header X-First "1"
+ respond "Response"
+ header X-Second "2"
+ }
+}
+```
+
+# route stops at terminal handler (respond)
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+header "X-First" == "1"
+header "X-Second" not exists
+
+
+# Configure Caddy with route preserving handler order
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ route {
+ vars step1 "done"
+ vars step2 "done"
+ vars step3 "done"
+ respond "{vars.step1},{vars.step2},{vars.step3}"
+ }
+}
+```
+
+# route preserves exact handler order
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "done,done,done"
+
+
+# Configure Caddy with route and matchers
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ route {
+ @api path /api/*
+ vars @api type "api"
+ vars type "other"
+ respond "{vars.type}"
+ }
+}
+```
+
+# route applies matchers in sequence
+GET https://localhost:9443/api/test
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "other"
+
+
+# route continues when matcher doesn't match
+GET https://localhost:9443/test
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "other"
diff --git a/caddytest/spec/http/static_response/spec.hurl b/caddytest/spec/http/static_response/spec.hurl
new file mode 100644
index 000000000..e3d0c0697
--- /dev/null
+++ b/caddytest/spec/http/static_response/spec.hurl
@@ -0,0 +1,105 @@
+# Configure Caddy
+POST http://localhost:2019/load
+User-Agent: hurl/ci
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ log
+ respond "Hello, World!"
+}
+```
+
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+`Hello, World!`
+
+
+GET https://localhost:9443/foo
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+`Hello, World!`
+
+# Configure Caddy
+POST http://localhost:2019/load
+User-Agent: hurl/ci
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ respond "New text!"
+}
+```
+
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP/2 200
+[Asserts]
+`New text!`
+
+
+GET https://localhost:9443/foo
+[Options]
+insecure: true
+HTTP/2 200
+[Asserts]
+`New text!`
+
+GET https://localhost:9443/foo
+[Options]
+insecure: true
+HTTP/2 200
+[Asserts]
+body != "Hello, World!"
+
+# Configure Caddy
+# The body is a placeholder
+POST http://localhost:2019/load
+User-Agent: hurl/ci
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ log
+ respond {http.request.body}
+}
+```
+
+# handler responds with the "application/json" if the response body is valid JSON
+POST https://localhost:9443
+[Options]
+insecure: true
+```json
+{
+ "greeting": "Hello, world!"
+}
+```
+HTTP/2 200
+[Asserts]
+header "Content-Type" == "application/json"
+```json
+{
+ "greeting": "Hello, world!"
+}
+```
diff --git a/caddytest/spec/http/uri/spec.hurl b/caddytest/spec/http/uri/spec.hurl
new file mode 100644
index 000000000..b3402531c
--- /dev/null
+++ b/caddytest/spec/http/uri/spec.hurl
@@ -0,0 +1,191 @@
+# Configure Caddy with uri strip_prefix
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ uri strip_prefix /api
+ respond {uri}
+}
+```
+
+# strip_prefix removes the prefix from the URI
+GET https://localhost:9443/api/users
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/users"
+
+
+# URI without prefix is unchanged
+GET https://localhost:9443/users
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/users"
+
+
+# Configure Caddy with uri strip_suffix
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ uri strip_suffix .php
+ respond {uri}
+}
+```
+
+# strip_suffix removes the suffix from the URI
+GET https://localhost:9443/index.php
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/index"
+
+
+# URI without suffix is unchanged
+GET https://localhost:9443/index.html
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/index.html"
+
+
+# Configure Caddy with uri replace
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ uri replace old new
+ respond {uri}
+}
+```
+
+# replace substitutes all occurrences
+GET https://localhost:9443/old/path/old
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/new/path/new"
+
+
+# Configure Caddy with uri path_regexp
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ uri path_regexp /([0-9]+) /$1/id
+ respond {uri}
+}
+```
+
+# path_regexp replaces using regular expressions
+GET https://localhost:9443/123
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "/123/id"
+
+
+# Configure Caddy with uri query operations
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ uri query +foo bar
+ respond {query}
+}
+```
+
+# query operations add parameters
+GET https://localhost:9443/?existing=value
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "existing=value&foo=bar"
+
+
+# Configure Caddy with uri query delete
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ uri query -sensitive
+ respond {query}
+}
+```
+
+# query operations delete parameters
+GET https://localhost:9443/?keep=this&sensitive=secret
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "keep=this"
+
+
+# Configure Caddy with uri query rename
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ uri query old>new
+ respond {query}
+}
+```
+
+# query operations rename parameters
+GET https://localhost:9443/?old=value
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "new=value"
diff --git a/caddytest/spec/http/vars/spec.hurl b/caddytest/spec/http/vars/spec.hurl
new file mode 100644
index 000000000..effa1d4cb
--- /dev/null
+++ b/caddytest/spec/http/vars/spec.hurl
@@ -0,0 +1,125 @@
+# Configure Caddy with vars directive
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ vars my_var "custom_value"
+ vars another_var "another_value"
+ respond "{vars.my_var} {vars.another_var}"
+}
+```
+
+# Variables are accessible in placeholders
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "custom_value another_value"
+
+
+# Configure Caddy with vars using placeholders
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ vars request_path {path}
+ vars request_method {method}
+ respond "Path: {vars.request_path}, Method: {vars.request_method}"
+}
+```
+
+# Variables can be set from request placeholders
+GET https://localhost:9443/test/path
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "Path: /test/path, Method: GET"
+
+
+# POST method is captured correctly
+POST https://localhost:9443/another
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "Path: /another, Method: POST"
+
+
+# Configure Caddy with vars in route
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ route /api/* {
+ vars api_version "v1"
+ respond "API {vars.api_version}"
+ }
+ respond "Not API"
+}
+```
+
+# Variables are scoped to their route
+GET https://localhost:9443/api/users
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "API v1"
+
+
+# Outside the route, variables are not set
+GET https://localhost:9443/other
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "Not API"
+
+
+# Configure Caddy with vars overwriting
+POST http://localhost:2019/load
+Content-Type: text/caddyfile
+```
+{
+ skip_install_trust
+ http_port 9080
+ https_port 9443
+ local_certs
+}
+localhost {
+ # without `route`, middlewares are sorted an unstable sort
+ route {
+ vars my_var "2"
+ vars my_var "1"
+ }
+ respond "{vars.my_var}"
+}
+```
+
+# Later vars directives overwrite earlier ones
+GET https://localhost:9443
+[Options]
+insecure: true
+HTTP 200
+[Asserts]
+body == "1"
diff --git a/caddytest/spec/hurl_vars.properties b/caddytest/spec/hurl_vars.properties
new file mode 100644
index 000000000..a3832eaea
--- /dev/null
+++ b/caddytest/spec/hurl_vars.properties
@@ -0,0 +1,2 @@
+indexed_root=caddytest/spec/http/file_server/assets/indexed
+unindexed_root=caddytest/spec/http/file_server/assets/unindexed
\ No newline at end of file