Skip to content

Proxy an application

A reverse proxy exposes one public address for one or more application instances. This page explains single and multiple upstreams, health checks, timeouts, backup upstreams, and the headers forwarded to the application.

📌 This page describes v0.2.2.

  • Pingclair installed and running (Install), with the service stopped while you experiment: sudo pc service stop.
  • An application listening on a local port. The examples here use 127.0.0.1:3000.
  • A port for the proxy itself: :8080 in the examples.
{
admin 127.0.0.1:2019
}
http://:8080 {
reverse_proxy 127.0.0.1:3000
}
Terminal window
sudo cp Pingclairfile /etc/Pingclair/Pingclairfile
sudo pingclair validate /etc/Pingclair/Pingclairfile
sudo kill -USR1 "$(systemctl show -p MainPID --value pingclair)"
curl -i http://localhost:8080/

The response is the application’s, with its own headers. The admin option lets pingclair reload reach the running server; the SIGUSR1 reload shown above works without it (what a reload means).

List the instances with to, then choose how traffic is divided:

http://:8080 {
reverse_proxy {
to 127.0.0.1:3000
to 127.0.0.1:3001
lb_policy round_robin
}
}

With an application that reports which port answered, six requests alternate between the two instances:

3000 3001 3000 3001 3000 3001
lb_policy Behavior
round_robin Select upstreams in rotation.
random Select an upstream at random; the default.
least_conn The upstream with the fewest connections in flight.
ip_hash The same client address always reaches the same upstream.
first The first available upstream.
header <name>, cookie <name>, query <name> Hash on that field, so requests from the same session use one instance.
weighted_round_robin <w> … One weight per upstream, on the same line.

A weight of 0 drains an upstream. Weights above 100 and pools whose every primary has weight 0 are refused. lb_try_duration limits when a new retry may begin, not how long an active response may run. Once an upstream may have seen the request, automatic retries repeat only idempotent methods.

Weights can also be configured separately for each upstream:

http://:8080 {
reverse_proxy {
to 127.0.0.1:3000 {
weight 3
}
to 127.0.0.1:3001
}
}

⚠️ lb_policy weighted_round_robin 3 1 matches its weights to the upstreams written above it, so the to lines must come before it. In the other order, validate refuses the file with 2 weights were given for 0 upstreams.

An upstream marked backup is used only when every other upstream is unavailable:

http://:8080 {
reverse_proxy {
to 127.0.0.1:3000
to 127.0.0.1:3001 {
backup
}
}
}

With both upstreams available, every request is sent to 3000. After that process stops, the next request is answered by 3001.

Without a health check, an upstream leaves rotation only after a request to it fails. A health check probes each upstream in the background and removes a failing one before a user request reaches it:

http://:8080 {
reverse_proxy {
to 127.0.0.1:3000
to 127.0.0.1:3001
health_check {
path /health
interval 2s
timeout 1s
status 200
consecutive_failure 2
consecutive_success 1
}
}
}

The application needs a health endpoint with low processing overhead, such as /health. State-change logs identify when an instance leaves rotation:

INFO pingclair_proxy::health_check: 🩺 Active upstream health changed backend=Inet(127.0.0.1:3001) healthy=false
INFO pingclair_proxy::health_check: 🩺 Active upstream health changed backend=Inet(127.0.0.1:3001) healthy=true

Measured on this configuration: with the second instance stopped, all traffic went to the first; when it recovered, it rejoined after consecutive_success successful probes. Caddy’s flat spelling (health_uri, health_interval, health_timeout, health_status, health_fails, health_passes) configures the same check.

Configure timeouts in a transport http block inside reverse_proxy:

http://:8080 {
reverse_proxy {
to 127.0.0.1:3099
to 127.0.0.1:3000
transport http {
connect_timeout 1s
first_byte_timeout 1s
read_timeout 30s
write_timeout 30s
}
}
}

Measured: with no connections accepted at 127.0.0.1:3099, the connect_timeout 1s deadline expires after one second. The request is then retried against the second upstream, which returns 200. An application that accepts the connection and then waits 3 seconds for a body is subject to first_byte_timeout 1s instead, and the client receives 504.

dial_timeout is not a reverse_proxy option; written there, validate refuses the file with Unknown directive 'reverse_proxy: dial_timeout'. Inside transport http, both dial_timeout and connect_timeout are accepted.

A hostname upstream supports applications whose IP address may change, such as containers recreated with a new address:

{
dns_refresh 5s
}
http://:8080 {
reverse_proxy {
to api.internal:3000
}
}

The name is resolved again on that interval, and each refresh is logged:

INFO pingclair_proxy::dns: 🔄 Upstream DNS scheduler enabled interval_secs=5 pools=1
INFO pingclair_proxy::dns: 🔄 Upstream DNS refresh changed=1 adopted=0 kept_stale=0 unresolved=0

Measured with /etc/hosts as the source of truth: pointing api.internal at 127.0.0.1 served the first instance, editing the file to 127.0.0.2 served the second within the interval, with no restart and no request failing. A lookup that fails keeps the previous address in rotation.

The application receives the original Host and the client’s address in the usual headers:

{
"host": "127.0.0.1:8080",
"x_forwarded_for": "127.0.0.1",
"x_forwarded_proto": "http",
"x_real_ip": "127.0.0.1"
}

Behind another proxy, the address in those headers is that proxy’s unless it is listed in trusted_proxies; the Cloudflare Tunnel guide covers that case.

  • 502 from the proxy. No upstream answered. Check that the application is listening (sudo ss -ltnp | grep :3000) and that the address matches. A built-in 502 or 504 that Pingclair generated carries Proxy-Status: pingclair; error=…; a custom handle_errors response omits it too, so absence alone does not identify the application.
  • 504 after a pause. A timeout expired: first_byte_timeout for a slow backend, read_timeout for a slow body, connect_timeout for a host that never accepts.
  • Unknown directive 'reverse_proxy: …'. The option belongs to a nested block — timeouts under transport http, checks under health_check — and validate names the exact spelling it refused.
  • A configuration change does not take effect. A reload cannot add or move a listener. When the new file does, the unit’s status line names the addresses that changed, and sudo pc service restart applies them. See Run it as a service.
  • Every request is sent to one instance. Check whether it is the only healthy upstream. Health-check logs identify when other instances left rotation and the reason (ConnectRefused, failure_statuses, and so on).

📌 A backend that fails after the connection — a truncated body, a reset before the response ended, a malformed response — counts against its passive health, and Caddy’s max_fails and fail_duration decide how long it stays out of rotation. CHANGELOG.