Skip to content

Configuration

Server binaries (pgway, pgway-cp, pgway-dp) load settings the same way: a TOML config file is required, then optional environment variables and flags override it.

pgctl does not read a config file at all — see CLI (--host / --port / --token-path flags).

Process/runtime config is TOML. Control-plane resource specs (pgctl apply, stack.yaml) remain YAML.

Precedence

Highest wins:

  1. Flags — --config <path>
  2. Environment — PGWAY_ + key in uppercase; nested keys use _ for . (e.g. PGWAY_LOG_LEVEL, PGWAY_GRPC_LISTEN_ADDR, PGWAY_AGENT_REGISTRATION_TOKEN)
  3. Config file
  4. Built-in defaults (see table below)

A config file must exist on a search path (or via --config) even if you override every value with env vars — for the server binaries; pgctl runs without one.

Where the file is loaded from

With --config /path/to/file.toml, that file is used exclusively.

Otherwise the process looks for config.toml in:

  1. /etc/pgway/
  2. $HOME/.pgway/
  3. . (current working directory)

Starting point in the repo: copy the checked-in defaults, then tweak paths for your machine:

cp config/default.toml ./config.toml
# or:
# mkdir -p ~/.pgway && cp config/default.toml ~/.pgway/config.toml

Local development

Prefer a project-local path for Badger and agent state (for example under ./var/…) so you do not need root under /var/pgway.

Default config file

Canonical source in the main repository:

config/default.toml

Full contents (keep this in sync with main when the file changes):

# pgway default configuration (pgway, pgway-cp, pgway-dp — pgctl does not
# read a config file; it takes --host/--port/--token-path flags).
# Every key can also be set via a PGWAY_-prefixed env var (e.g.
# PGWAY_BADGER_PATH, PGWAY_AGENT_REGISTRATION_TOKEN) or, for the file location,
# the --config flag. Nested keys use underscores in env names (badger.path →
# PGWAY_BADGER_PATH). Precedence:
#   flags > env vars > this file > built-in defaults.

log_level = "info"

[badger]
# BadgerDB data directory (pgway, pgway-cp).
path = "/var/pgway/lib"
# How often to run Badger value log GC (pgway, pgway-cp). 0 disables.
gc_interval = "5m"

[grpc]
# gRPC listen address for the control plane. pgway-dp dials grpc.dial_addr,
# which falls back to this address when unset.
listen_addr = ":9090"
# gRPC keepalive ping interval (server + client). 0 disables.
keepalive_interval = "1m"
# How long to wait for a keepalive ping ACK. Required when interval > 0.
keepalive_timeout = "20s"
# Per-client unary RPC token-bucket rate (tokens/sec). 0 disables.
rate_limit_rps = 100
# Token-bucket burst capacity. Required when rate_limit_rps > 0.
rate_limit_burst = 200

[dashboard]
# Start the dashboard HTTP surface (JSON API + embedded UI when built with
# -tags embeddashboard). On by default — set to false when unused.
enabled = true
# Prefer loopback until you put a reverse proxy / trusted network in front.
listen_addr = "127.0.0.1:8081"
# Browser Origins allowed for CORS. Empty = no cross-origin (no reflective Origin).
# Local Nuxt dev typically needs: ["http://localhost:3000"]
cors_allow_origins = []
# Token-bucket for dashboard HTTP (0 = off). Applied per client IP before auth and
# per user after auth, so token brute-force is capped too.
rate_limit_rps = 100
rate_limit_burst = 200

[probes]
# Opt-in process liveness/readiness HTTP listener (K8s probes). Off by default.
enabled = false
# Dedicated bind address — prefer loopback or cluster-internal (e.g. 127.0.0.1:8082).
# Do not reuse entrypoint / gRPC / REST ports; avoid binding on public interfaces.
listen_addr = ":8082"

[auth]
# Default lifetime of login-issued tokens (pgway, pgway-cp).
token_ttl = "720h"
# Single-use agent registration token default TTL (pgway, pgway-cp).
registration_token_ttl = "24h"
# Sliding TTL for per-agent bearer tokens; extended on every heartbeat.
agent_token_ttl = "168h"

[agent]
# Unique agent name registered with the CP. Empty → hostname.
name = ""
# Operator-declared labels advertised at Register (seed for placement).
labels = {}
# Persisted agent credentials ({agent_id, agent_token}). Dir 0700, file 0600.
state_path = "/var/lib/pgway/agent.json"
# How often pgway-dp sends Heartbeat RPCs.
heartbeat_interval = "10s"
# Derived agent status boundary: within → active, beyond → disconnected.
heartbeat_threshold = "30s"
# First-time Register secret. Prefer PGWAY_AGENT_REGISTRATION_TOKEN; leave empty
# when agent.state_path already holds credentials.
registration_token = ""

[proxy]
# Max request body for non-CONNECT proxy HTTP. 0 = unlimited.
# Accepts integers or human sizes (10MiB, 512KiB, 100MB).
# Also PGWAY_PROXY_MAX_REQUEST_BODY_BYTES.
max_request_body_bytes = "10MiB"
# Per-proxy http.Transport pool (pgway, pgway-dp). 0 max_idle_conns = unlimited.
max_idle_conns = 1024
max_idle_conns_per_host = 128
idle_conn_timeout = "90s"
dial_timeout = "10s"

[proxy.dns_cache]
# Fixed-TTL cache for upstream proxy hostname resolution (pgway, pgway-dp).
# enabled=false uses the default resolver on every dial.
enabled = true
# How long successful lookups are reused. Must be > 0 when enabled.
ttl = "5m"

[dataplane]
# setTimeout-style change-event coalesce window (pgway, pgway-dp). 0 disables.
event_coalesce_window = "100ms"
# Early flush when this many events land in one key before the window ends.
event_coalesce_max_buffer = 256
# All-in-one missed-event safety net (Bootstrap + listener reconcile). 0 disables.
# Ignored by pgway-dp (Watch reconnect is the primary heal path).
# Default 5m avoids resetting LB cursors too often when config is unchanged.
event_resync_interval = "5m"
# Distributed DP: fail_open keeps serving last-known config when CP is down;
# fail_closed rejects new proxy requests with 503 after unreachable (see threshold note).
cp_disconnect_strategy = "fail_open"
# Both HB and Watch must be stale, then remain so for this duration, before
# unreachable. After CP death this can take up to ~2× this value (HB age-out + window).
cp_disconnect_unreachable_threshold = "30s"
# Hysteresis before leaving unreachable once proofs are fresh again. 0 = immediate.
cp_disconnect_recover_threshold = "0s"

[otel]
# Opt-in OpenTelemetry metrics (OTLP/gRPC push to your collector). Off by default.
# Master switch: traces also require enabled = true.
enabled = false
# OTLP/gRPC collector host:port (no scheme).
endpoint = "localhost:4317"
# Use plaintext OTLP/gRPC (no TLS). Default false = TLS to the collector.
insecure = false
# Resource service.name; empty → binary default (pgway / pgway-cp / pgway-dp).
service_name = ""
# Periodic metric export interval.
export_interval = "15s"
# Opt-in distributed tracing (OTLP/gRPC spans). Requires enabled = true.
traces_enabled = false
# ParentBased + TraceIDRatioBased sample fraction (0.0–1.0). Default 0.1 = 10%.
trace_sample_ratio = 0.1

Keys

Key Env Default Used by Description
log_level PGWAY_LOG_LEVEL info all debug | info | warn | error
badger.path PGWAY_BADGER_PATH /var/pgway/lib pgway, pgway-cp BadgerDB directory
badger.gc_interval PGWAY_BADGER_GC_INTERVAL 5m pgway, pgway-cp Badger value log GC period; 0 disables
grpc.listen_addr PGWAY_GRPC_LISTEN_ADDR :9090 all CP listen address (pgway / pgway-cp)
grpc.dial_addr PGWAY_GRPC_DIAL_ADDR (empty → listen_addr) pgway-dp CP address to dial; leave empty to reuse listen_addr locally
grpc.keepalive_interval PGWAY_GRPC_KEEPALIVE_INTERVAL 1m all gRPC keepalive ping period; 0 disables
grpc.keepalive_timeout PGWAY_GRPC_KEEPALIVE_TIMEOUT 20s all Keepalive ping ACK wait; must be > 0 when interval is enabled
grpc.rate_limit_rps PGWAY_GRPC_RATE_LIMIT_RPS 100 pgway, pgway-cp Per-client unary RPC token-bucket rate; 0 disables
grpc.rate_limit_burst PGWAY_GRPC_RATE_LIMIT_BURST 200 pgway, pgway-cp Token-bucket burst; must be >= 1 when rps is enabled
dashboard.enabled PGWAY_DASHBOARD_ENABLED true pgway, pgway-cp Start the dashboard HTTP surface (API + UI; experimental)
dashboard.listen_addr PGWAY_DASHBOARD_LISTEN_ADDR 127.0.0.1:8081 pgway, pgway-cp Dashboard bind address (loopback by default)
dashboard.cors_allow_origins PGWAY_DASHBOARD_CORS_ALLOW_ORIGINS [] pgway, pgway-cp Allowed browser Origins; empty = no CORS
dashboard.rate_limit_rps PGWAY_DASHBOARD_RATE_LIMIT_RPS 100 pgway, pgway-cp Dashboard HTTP token-bucket rate, per client IP (pre-auth) and per user (post-auth); 0 disables
dashboard.rate_limit_burst PGWAY_DASHBOARD_RATE_LIMIT_BURST 200 pgway, pgway-cp Token-bucket burst; must be >= 1 when rps is enabled
probes.enabled PGWAY_PROBES_ENABLED false all Start dedicated /healthz + /readyz listener
probes.listen_addr PGWAY_PROBES_LISTEN_ADDR :8082 all Probe bind address; prefer 127.0.0.1:8082 or cluster-internal; do not expose publicly
auth.token_ttl PGWAY_AUTH_TOKEN_TTL 720h pgway, pgway-cp Default login token lifetime
auth.registration_token_ttl PGWAY_AUTH_REGISTRATION_TOKEN_TTL 24h pgway, pgway-cp Default TTL for single-use agent registration tokens
auth.agent_token_ttl PGWAY_AUTH_AGENT_TOKEN_TTL 168h pgway, pgway-cp Sliding TTL for per-agent tokens
agent.name PGWAY_AGENT_NAME (hostname) pgway-dp Unique agent identity
agent.labels — {} pgway-dp Labels advertised at Register
agent.state_path PGWAY_AGENT_STATE_PATH /var/lib/pgway/agent.json pgway-dp Persisted {agent_id, agent_token} (dir 0700, file 0600)
agent.heartbeat_interval PGWAY_AGENT_HEARTBEAT_INTERVAL 10s pgway-dp Heartbeat period
agent.heartbeat_threshold PGWAY_AGENT_HEARTBEAT_THRESHOLD 30s pgway, pgway-cp Active vs disconnected boundary
agent.registration_token PGWAY_AGENT_REGISTRATION_TOKEN (empty) pgway-dp First Register secret
proxy.max_request_body_bytes PGWAY_PROXY_MAX_REQUEST_BODY_BYTES 10MiB pgway, pgway-dp Cap for non-CONNECT bodies; 0 = unlimited
proxy.max_idle_conns PGWAY_PROXY_MAX_IDLE_CONNS 1024 pgway, pgway-dp Global idle conn limit; 0 = unlimited
proxy.max_idle_conns_per_host PGWAY_PROXY_MAX_IDLE_CONNS_PER_HOST 128 pgway, pgway-dp Idle conns per upstream host; must be > 0
proxy.idle_conn_timeout PGWAY_PROXY_IDLE_CONN_TIMEOUT 90s pgway, pgway-dp How long idle pooled connections are kept
proxy.dial_timeout PGWAY_PROXY_DIAL_TIMEOUT 10s pgway, pgway-dp TCP dial timeout to upstream proxies
proxy.dns_cache.enabled PGWAY_PROXY_DNS_CACHE_ENABLED true pgway, pgway-dp Cache upstream proxy hostname lookups; false = default resolver every dial
proxy.dns_cache.ttl PGWAY_PROXY_DNS_CACHE_TTL 5m pgway, pgway-dp How long successful lookups are reused; must be > 0 when enabled
dataplane.event_coalesce_window PGWAY_DATAPLANE_EVENT_COALESCE_WINDOW 100ms pgway, pgway-dp Change-event coalesce delay; 0 disables (immediate dispatch)
dataplane.event_coalesce_max_buffer PGWAY_DATAPLANE_EVENT_COALESCE_MAX_BUFFER 256 pgway, pgway-dp Early flush threshold per coalesce key; must be >= 1 when window is enabled
dataplane.event_resync_interval PGWAY_DATAPLANE_EVENT_RESYNC_INTERVAL 5m pgway Periodic full Resync (missed-event heal); 0 disables; ignored by pgway-dp
dataplane.cp_disconnect_strategy PGWAY_DATAPLANE_CP_DISCONNECT_STRATEGY fail_open pgway-dp fail_open | fail_closed when CP unreachable
dataplane.cp_disconnect_unreachable_threshold PGWAY_DATAPLANE_CP_DISCONNECT_UNREACHABLE_THRESHOLD 30s pgway-dp Both proofs stale, then this long → unreachable (≈ up to 2× after CP death)
dataplane.cp_disconnect_recover_threshold PGWAY_DATAPLANE_CP_DISCONNECT_RECOVER_THRESHOLD 0s pgway-dp Hysteresis leaving unreachable; 0 = immediate
otel.enabled PGWAY_OTEL_ENABLED false all Master switch: enable OTLP/gRPC metrics push (required for traces too)
otel.endpoint PGWAY_OTEL_ENDPOINT localhost:4317 all OTLP/gRPC collector host:port (no scheme); required when enabled
otel.insecure PGWAY_OTEL_INSECURE false all Plaintext OTLP/gRPC (no TLS); default false = TLS to the collector
otel.service_name PGWAY_OTEL_SERVICE_NAME (binary name) all Resource service.name; empty → pgway / pgway-cp / pgway-dp
otel.export_interval PGWAY_OTEL_EXPORT_INTERVAL 15s all Periodic metric export interval; must be > 0 when enabled
otel.traces_enabled PGWAY_OTEL_TRACES_ENABLED false all Opt-in OTLP/gRPC span export; requires otel.enabled = true
otel.trace_sample_ratio PGWAY_OTEL_TRACE_SAMPLE_RATIO 0.1 all Sample fraction 0.0–1.0 (ParentBased + TraceIDRatioBased); validated when traces enabled

pgctl does not use any of these keys. Its token (after init / login) lives under ~/.pgctl/credentials by default — see the --token-path flag in CLI.

Examples

All-in-one (pgway) — local

# config.toml
log_level = "info"

[badger]
path = "./var/lib"

[grpc]
listen_addr = ":9090"

[dashboard]
enabled = true
listen_addr = "127.0.0.1:8081"
cors_allow_origins = ["http://localhost:3000"]

[auth]
token_ttl = "720h"
./build/pgway --config ./config.toml
# or from the directory that contains config.toml:
./build/pgway

Control Plane only (pgway-cp)

Same shape as all-in-one for storage and listen addresses; no entrypoint traffic.

log_level = "info"

[badger]
path = "/var/pgway/lib"

[grpc]
listen_addr = ":9090"

[dashboard]
listen_addr = "127.0.0.1:8081"

Data Plane agent (pgway-dp)

Prefer grpc.dial_addr for the CP address to dial. If empty, clients fall back to grpc.listen_addr (legacy / local convenience).

# dp.toml
log_level = "info"

[grpc]
dial_addr = "cp-host:9090"

[agent]
name = "edge-1"
labels = { zone = "edge" }
state_path = "./var/agent.json"
heartbeat_interval = "10s"

First start needs a one-time registration token (created on the CP with pgctl agent token create); later starts reuse agent.state_path. Details belong in the agent / first-run guides.

CLI (pgctl)

pgctl is config-file free — no TOML, no PGWAY_* env vars. Address and token file come from flags:

./build/pgctl -H 127.0.0.1 -P 9090 get proxy
# defaults are loopback:9090, so locally just:
./build/pgctl get proxy
# custom token file:
./build/pgctl --token-path /etc/pgctl/token get proxy

Dashboard HTTP surface

dashboard.enabled starts the Control Plane dashboard HTTP surface (bound to dashboard.listen_addr, default 127.0.0.1:8081): JSON under /api/v1/* and, when the binary was built with -tags embeddashboard (release / Docker / make build-embed), the static UI at /. Session endpoints: POST /api/v1/auth/login (public), POST /api/v1/auth/logout, GET /api/v1/auth/me. Other management routes require a user session via Authorization: Bearer … or the httpOnly pgway_token cookie (same token store as gRPC/pgctl login). CORS is an explicit allowlist with credentials (dashboard.cors_allow_origins; empty = no browser cross-origin). Rate limits (dashboard.rate_limit_rps / burst) apply twice: per client IP before auth (capping token brute-force and anonymous traffic) and per user after auth.

The surface remains experimental. Prefer gRPC + pgctl for production configuration, and set dashboard.enabled = false where unused. Release binaries: open http://127.0.0.1:8081/ (same origin — no CORS). Local Nuxt dev: add your UI origin to cors_allow_origins (e.g. http://localhost:3000) and call the API as http://localhost:8081 so the session cookie stays same-site.

What’s next

First run — bootstrap admin, apply a minimal stack, send traffic. See also Binaries & planes and Installation.