Skip to the content.

Admin API Reference

The Admin API is a local HTTP server for managing a running Conduit instance: hot-reloading config, inspecting upstream health, adjusting traffic routing, and graceful shutdown — all without restarting the process.


Table of Contents


Setup

The Admin HTTP server only starts when global.admin is explicitly configured. Without it, no port is opened and the API is completely inaccessible.

Internal background tasks (upstream health checks, rate-limiter cleanup, hot-reload file watcher) run regardless of admin config.

# conduit.yaml
global:
  admin:
    bind: "127.0.0.1:2019" # required — loopback only
    token: "$ADMIN_TOKEN" # strongly recommended in production
// conduit.json
{ "global": { "admin": { "bind": "127.0.0.1:2019", "token": "$ADMIN_TOKEN" } } }

Security: Keep bind on loopback (127.0.0.1). Never bind to 0.0.0.0 without a VPN or SSH tunnel. Without a token, anyone with access to that address can reload configs, add upstreams, or shut down the server.


Authentication

The Admin API uses Bearer token authentication only — no cookies, no Basic Auth, no JWT.

Generating a token

Use any method that produces a cryptographically random string. Store the result in an environment variable — never hardcode it in the config file.

# openssl (recommended — available on Linux, macOS, Windows via Git Bash)
openssl rand -hex 32
# → e.g. a3f8c2d1b4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1

# /dev/urandom (Linux / macOS)
cat /dev/urandom | head -c 32 | base64

# Python 3
python3 -c "import secrets; print(secrets.token_hex(32))"

# Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# PowerShell (Windows)
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Max 256 }))

Set it as an environment variable and reference it from the config:

# Generate once and save
export ADMIN_TOKEN=$(openssl rand -hex 32)

# Or store in a .env file (loaded by systemd EnvironmentFile=)
echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> /etc/conduit/conduit.env
# conduit.yaml — reference the env var
global:
  admin:
    bind: "127.0.0.1:2019"
    token: "$ADMIN_TOKEN"

Using the token

When global.admin.token is set, every request must include:

Authorization: Bearer <token>

Requests without the correct token receive 401 Unauthorized.

# Without token (works when token is not configured)
curl http://localhost:2019/status

# With token
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:2019/status

# Using CONDUIT_ADMIN env var for address + inline token
export CONDUIT_ADMIN=127.0.0.1:2019
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://$CONDUIT_ADMIN/status

The CONDUIT_ADMIN env var sets the address used by CLI shortcuts. The token must always be passed explicitly via the Authorization header (or via conduit CLI commands that read it from CONDUIT_ADMIN_TOKEN).


Endpoints

GET /status

Returns server status, version, and upstream health summary.

curl http://localhost:2019/status

Response:

{
  "status": "running",
  "inflight": 42,
  "retry_inflight": 3,
  "sites": 2,
  "configured_upstreams": 4,
  "healthy_upstreams": 3,
  "total_probed_upstreams": 4,
  "config_path": "/etc/conduit/conduit.yaml"
}
Field Description
status Always "running" when the server is up
inflight Requests currently being processed
retry_inflight Requests currently in a retry attempt
sites Number of configured virtual sites
configured_upstreams Total upstream targets across all routes
healthy_upstreams Upstreams currently passing health probes
total_probed_upstreams Upstreams that have been probed at least once
config_path Path to the loaded config file

POST /reload

Re-reads the config file from disk, validates it, and applies all hot-reloadable changes without restarting.

curl -X POST http://localhost:2019/reload

On success:

{ "status": "ok", "message": "config reloaded" }

On success with feature warnings (feature configured but not compiled in):

{
  "status": "ok",
  "message": "config reloaded",
  "warnings": ["jwtAuth is configured but --features jwt was not compiled in"]
}

On validation error (400):

{
  "status": "error",
  "message": "config error at proxy./api.retry.attempts: must be > 0"
}

On cold field change (400):

{
  "status": "error",
  "message": "cold fields changed — restart required: sites[0].tls.cert"
}

Cold fields must be changed via a process restart (systemctl restart conduit), not via POST /reload.

What’s hot-reloadable (applied immediately, no restart):
proxy, static, routes, rateLimit, basicAuth, apiKey, jwtAuth, forwardAuth, consumers, middleware, logging, cors, securityHeaders, cache, outlierDetection, limits, requestTransform, responseTransform, maskErrors.

What requires a cold restart:
port, tls.cert/key, tls.versions/ciphers, workers, backlog, global.admin.bind.

Note: POST /reload resets runtime upstream overrides (added via /upstreams/add, /upstreams/remove, /upstreams/weight) and clears in-memory rate-limiter counters. Dynamic IP deny entries (POST /ip-deny) are not reset on reload — they persist until the process restarts.


POST /shutdown

Initiates a graceful shutdown: stops accepting new connections, waits for all in-flight requests to complete, then exits.

curl -X POST http://localhost:2019/shutdown

Response:

{ "status": "shutting_down" }

The shutdown timeout is controlled by global.shutdownTimeoutSecs.


GET /upstreams

Returns health, latency, and routing information for all upstream targets.

curl http://localhost:2019/upstreams

Response:

{
  "upstreams": [
    {
      "url": "http://api-1:4000",
      "healthy": true,
      "state": "healthy",
      "latency_ms": 12,
      "ewma_latency_ms": 11,
      "consecutive_failures": 0,
      "consecutive_successes": 5,
      "consecutive_5xx": 0,
      "active_connections": 2,
      "ejected": false,
      "responses": { "2xx": 9821, "4xx": 3, "5xx": 0 },
      "selected":  { "total": 9824, "last_secs": 1749123456 }
    },
    {
      "url": "http://api-2:4000",
      "healthy": false,
      "state": "ejected",
      "latency_ms": null,
      "ewma_latency_ms": 0,
      "consecutive_failures": 3,
      "consecutive_successes": 0,
      "consecutive_5xx": 5,
      "active_connections": 0,
      "ejected": true,
      "responses": { "2xx": 4100, "4xx": 1, "5xx": 5 },
      "selected":  { "total": 4106, "last_secs": 1749120000 }
    }
  ],
  "routes": [
    {
      "site": "api.example.com:443",
      "path": "/v1",
      "strategy": "least-conn",
      "targets": [
        {
          "url": "http://api-1:4000",
          "weight": 1,
          "healthy": true,
          "latency_ms": 12,
          "consecutive_failures": 0,
          "consecutive_successes": 5
        },
        {
          "url": "http://api-2:4000",
          "weight": 1,
          "healthy": false,
          "latency_ms": null,
          "consecutive_failures": 3,
          "consecutive_successes": 0
        }
      ]
    }
  ]
}

The response has two sections:

Upstream state values:

state Meaning
healthy Passing health checks, no active connections
busy Active connections ≥ 1 (may indicate high load)
unhealthy Failing health checks
half-open Ejection expired; next probe request will decide full recovery
ejected Blocked by outlier detection; will re-join after backoff expires

New fields (v1.1+):

Field Description
state Human-readable state string (see table above)
ewma_latency_ms Exponentially-weighted moving average latency (passive, all traffic)
consecutive_5xx Consecutive 5xx responses since last success
active_connections In-flight requests currently being processed by this upstream
responses Lifetime totals: 2xx, 4xx, 5xx response counts
selected.total Total times this upstream was chosen by the load balancer
selected.last_secs Unix timestamp of the most recent selection

POST /upstreams/add

Add an upstream target to a route at runtime. In-memory only — reset on POST /reload.

curl -X POST http://localhost:2019/upstreams/add \
     -H "Content-Type: application/json" \
     -d '{"route": "/api", "target": "http://api-3:4000"}'

Request body:

Field Required Description
route Route path prefix, e.g. "/api"
target Full upstream URL, e.g. "http://api-3:4000"
weight Weight for weighted-round-robin (default: 1)
site Scope to a specific site label, e.g. "api.example.com:443". Omit to apply to all sites with this route

Response:

{
  "status": "ok",
  "site": "*",
  "route": "/api",
  "target": "http://api-3:4000",
  "weight": 1
}

POST /upstreams/remove

Remove an upstream target from a route at runtime.

curl -X POST http://localhost:2019/upstreams/remove \
     -H "Content-Type: application/json" \
     -d '{"route": "/api", "target": "http://api-3:4000"}'

Request body: route and target (required), site (optional).

Response:

{
  "status": "ok",
  "removed": true,
  "site": "*",
  "route": "/api",
  "target": "http://api-3:4000"
}

"removed": false when the target was not found for the given route.


POST /upstreams/weight

Change the weight of an upstream target (effective for weighted-round-robin strategy only).

# Give api-1 three times more traffic than api-2
curl -X POST http://localhost:2019/upstreams/weight \
     -H "Content-Type: application/json" \
     -d '{"route": "/api", "target": "http://api-1:4000", "weight": 3}'

Request body: route, target, weight (all required), site (optional).

Response:

{
  "status": "ok",
  "site": "*",
  "route": "/api",
  "target": "http://api-1:4000",
  "weight": 3
}

This is the HTTP equivalent of the conduit upstreams weight CLI command.


DELETE /cache/purge

Invalidate a specific URL from the in-memory proxy cache.

curl -X DELETE "http://localhost:2019/cache/purge?url=https://api.example.com/v1/products"

Query parameter: url — the full URL to purge (scheme + host + path + query).

Response:

{ "status": "ok", "purged": true, "url": "https://api.example.com/v1/products" }

"purged": false when no matching entry was found in the cache.

Only the in-memory cache is supported. Redis cache purge is not yet implemented.


GET /rate-limits

Return accumulated pass/reject counters for every rate-limit bucket currently in memory. Useful for diagnosing which clients or routes are being throttled.

curl http://localhost:2019/rate-limits

Response:

{
  "app.example.com:8080": {
    "/api": {
      "passed":   12345,
      "rejected": 3
    }
  },
  "*": {
    "*": {
      "passed":   99000,
      "rejected": 12
    }
  }
}

The outer key is the site label (host:port or "*" for wildcard rules) and the inner key is the route prefix. Both passed and rejected are monotonically increasing counters that reset when the process restarts or POST /reload is called (reload clears in-memory rate-limiter state).


POST /ip-deny

Add a CIDR to the runtime deny-list. Takes effect immediately for all new requests. In-memory only — persists across POST /reload but resets on process restart.

curl -X POST http://localhost:2019/ip-deny \
     -H "Content-Type: application/json" \
     -d '{"cidr": "203.0.113.0/24"}'

Request body:

Field Required Description
cidr CIDR block or single IP, e.g. "203.0.113.0/24" or "10.0.0.5"

Response:

{ "status": "ok", "action": "added", "cidr": "203.0.113.0/24" }

To make the deny permanent (survives restarts), add the CIDR to ipFilter.deny in the config file and run POST /reload.

Note: there is no GET /ip-deny endpoint. To inspect the current runtime deny list, check the ipFilter.deny config and any CIDRs added at runtime — the dynamic list is not exposed via the API.


DELETE /ip-deny

Remove a CIDR from the runtime deny-list.

curl -X DELETE http://localhost:2019/ip-deny \
     -H "Content-Type: application/json" \
     -d '{"cidr": "203.0.113.0/24"}'

Response:

{ "status": "ok", "action": "removed", "cidr": "203.0.113.0/24" }

Always returns "action": "removed" even if the CIDR was not in the list.


POST /certs/reload

Validate a new TLS certificate + private key and write them atomically to the file paths configured in tls.cert / tls.key. A restart or conduit reload is required afterwards for the new certificate to take effect on new connections.

Why a restart? Pingora 0.8’s rustls backend builds an immutable ServerConfig at startup and has no runtime cert-swap API. Writing the files here is the safe atomic step; applying them without downtime will be possible once Pingora exposes a ResolvesServerCert hook (planned for 0.9+). For Let’s Encrypt, use tls.acme instead — renewals are fully automatic.

Request body:

{
  "cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
  "key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
}

Both fields accept a full PEM string (including any intermediate certificates chained after the leaf certificate for cert).

# Read new cert and key from files, send to Admin API
CERT=$(cat /tmp/new-server.crt)
KEY=$(cat /tmp/new-server.key)

curl -s -X POST http://localhost:2019/certs/reload \
     -H "Content-Type: application/json" \
     -H "Authorization: Bearer $TOKEN" \
     -d "{\"cert\": $(echo "$CERT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'), \
          \"key\":  $(echo "$KEY"  | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')}"

Or with jq (cleaner):

jq -n --rawfile cert /tmp/new-server.crt \
      --rawfile key  /tmp/new-server.key \
      '{cert: $cert, key: $key}' \
| curl -s -X POST http://localhost:2019/certs/reload \
       -H "Content-Type: application/json" \
       -H "Authorization: Bearer $TOKEN" \
       -d @-

Success response (200):

{
  "status": "ok",
  "cert_path": "/etc/conduit/tls/server.crt",
  "key_path": "/etc/conduit/tls/server.key",
  "note": "certificate written to disk — restart or POST /reload to activate"
}

Error responses:

Status Cause
400 No site has tls.cert/tls.key configured
400 Cert and key do not form a valid pair (mismatch, corrupt PEM, no cert found)
500 File write failed (permissions, disk full, …)

Typical workflow:

# 1. Upload and validate the new certificate
jq -n --rawfile cert new.crt --rawfile key new.key '{cert: $cert, key: $key}' \
  | curl -sX POST http://localhost:2019/certs/reload \
         -H "Content-Type: application/json" \
         -H "Authorization: Bearer $ADMIN_TOKEN" \
         -d @-

# 2. Apply — POST /reload works here because the cert/key file PATHS in config
#    did not change (only the file contents changed), so no cold-field error.
curl -sX POST http://localhost:2019/reload \
     -H "Authorization: Bearer $ADMIN_TOKEN"

# If the cert/key paths themselves changed in conduit.yaml, a full restart is needed:
# systemctl restart conduit

CLI shortcuts

The conduit binary has built-in commands that wrap the Admin API:

# Default address (127.0.0.1:2019) — no flag needed
conduit reload
conduit status
conduit shutdown

# Custom address via flag
conduit reload   --admin 10.0.0.5:2019
conduit status   --admin 10.0.0.5:2019
conduit shutdown --admin 10.0.0.5:2019

# Custom address via environment variable (useful in scripts)
export CONDUIT_ADMIN=10.0.0.5:2019
conduit reload
conduit status

# Upstream management
conduit upstreams
conduit upstreams add    --route /api --target http://api-3:4000
conduit upstreams add    --route /api --target http://api-3:4000 --weight 2
conduit upstreams remove --route /api --target http://api-3:4000
conduit upstreams weight --route /api --target http://api-1:4000 --weight 3

# Scope a change to one specific site only
conduit upstreams add --route /api --target http://api-3:4000 --site api.example.com:443

# Upstream health table (human-readable)
conduit status --upstream
CLI command Admin API call
conduit reload POST /reload
conduit status GET /status
conduit status --upstream GET /upstreams (formatted as table)
conduit shutdown POST /shutdown
conduit upstreams GET /upstreams
conduit upstreams add POST /upstreams/add
conduit upstreams remove POST /upstreams/remove
conduit upstreams weight POST /upstreams/weight

See cli.md for all flags.


Security

Keep the Admin API on loopback. Use 127.0.0.1 as the bind address — it is only reachable from the same host. Never use 0.0.0.0 without a VPN or SSH tunnel.

Always set a token in production. Without global.admin.token, anyone with local access can reload configs, add upstreams, or shut down the server.

global:
  admin:
    bind: "127.0.0.1:2019"
    token: "$ADMIN_TOKEN" # read from environment variable

Zero-downtime config update workflow:

# 1. Edit conduit.yaml
vim /etc/conduit/conduit.yaml

# 2. Validate before applying (exits 1 if invalid)
conduit validate -c /etc/conduit/conduit.yaml

# 3. Apply — no restart, no dropped connections
conduit reload --admin 127.0.0.1:2019