PQ-Proxy On-Premise
Installation & Operations Guide
00 Prerequisites
PQ-Proxy On-Premise runs as a set of Docker containers on a single server. Before installing, make sure you have the following in place.
Server requirements
| Requirement | Details |
|---|---|
| Operating system | Any Linux distribution that runs Docker Engine (Ubuntu, Debian, RHEL, etc.) |
| Docker |
Docker Engine ≥ 23 with the docker compose plugin.
If Docker isn't installed, the installer installs it for you automatically —
you don't need to install it yourself beforehand.
|
| CPU / RAM | No hard minimum — size according to your expected traffic. A small instance (1–2 vCPU, 1–2 GB RAM) is enough for low-to-moderate traffic across a handful of domains. The installer asks for your expected max simultaneous connections and sizes internal limits (like open file descriptors) accordingly. |
| Disk | Minimal — the stack itself is small. Size for your connection log and audit log retention (configurable, default 30 days). |
Ports
The installer maps the following ports to the host by default:
| Port | Purpose | Exposed by default? |
|---|---|---|
443/tcp |
Post-quantum TLS — where your visitors connect. Configurable: the installer asks for this port during setup (default 443), in case something else on the server already uses it. | Yes |
9090 |
Management API + dashboard (http://YOUR_SERVER_IP:9090/dashboard).
Fixed — not asked during install.
|
Yes |
9091 |
Internal server (Prometheus metrics, container health check). Fixed. | No — internal to the Docker network only, by design |
DNS
For each domain you plan to route through PQ-Proxy, point its DNS A/AAAA record to this server's public IP before adding the domain in the dashboard. PQ-Proxy uses SNI (Server Name Indication) to route incoming connections — a domain that isn't configured yet, or whose DNS hasn't propagated, won't have a matching backend and won't work.
Your backend
You'll need the address (host + port) of the service PQ-Proxy will forward traffic to — your existing application server, load balancer, or reverse proxy. It can be:
- On the same machine (
localhostor a private Docker network) - On your private/corporate LAN (10.x, 172.16–31.x, 192.168.x)
- Reachable over the public internet
It can speak plain HTTP or TLS — both are supported and configured per domain. Covered in detail in Section 04 — Managing domains.
01 Get a license
PQ-Proxy On-Premise offers two license tiers. Both are signed offline with ML-DSA-65 (NIST FIPS 204) — the license file itself proves its own authenticity, with no network call back to FIPSign required to validate it.
| Trial | Standard | |
|---|---|---|
| Duration | 14 days | 365 days |
| Domains | 1 | No limit |
| Server instances | 1 | 1 |
| Price | Free | $499/year |
| Features | Identical — everything in this guide applies to both tiers | |
Requesting a trial
Go to onprem.fipsign.dev/trial and submit your company name and email. A few things to know before you do:
- Corporate email required. Free email providers (Gmail, Outlook.com, Yahoo, etc.) are not accepted — use your company's own domain.
- One active trial per email domain every 30 days. If your company already requested a trial recently, a new one for the same domain won't be issued until that window passes.
- You'll receive a confirmation email — click the link to verify your email and get your license file. The link is single-use and expires after a period; if it's expired or already used, request a new trial.
Buying a Standard license
Go to onprem.fipsign.dev/purchase. The flow has an extra step compared to the trial, by design:
- Submit your company name and email.
- Click the confirmation link sent to that email. This locks in the exact email your license will be tied to before any payment happens — so a typo in the address can't send a paid license somewhere you don't control.
- You're redirected to Dodo Payments to complete payment (card or crypto).
- Once payment is confirmed, your license file and download link arrive by email — usually within minutes.
license.pqp
file, for example after a server migration.
Didn't receive the email?
Go to onprem.fipsign.dev/recover
(also linked in the footer of every page on the site) and enter the
email you used. If it has any active, non-expired licenses — trial or
standard — you'll receive a fresh download link for each of them in a
single email. This also works months later, if you've simply lost
your license.pqp file and need to recover it again.
02 Installation
Copy your license file to the server, then run one command. The installer downloads everything it needs, asks a series of configuration questions, and starts the stack.
scp license.pqp root@YOUR_SERVER_IP:/root/
curl -fsSL https://proxyonprem.fipsign.dev/install -o install.sh && bash install.sh
get.docker.com) — you don't need
to do this yourself beforehand.
What the installer asks
Every question has a sensible default (shown in brackets) — press Enter to accept it.
| Question | What it means |
|---|---|
| Database: bundled or external? | Bundled (recommended, default): runs Postgres as part of the stack, no setup needed. External: point to your own Postgres (AWS RDS, Supabase, Neon, etc.) — you provide the connection URL. |
| Max simultaneous connections to Postgres |
The connection pool size management-api uses.
Asked either way, bundled or external — but only validated in
one case:
|
| TLS port (post-quantum) | Default 443. Change it if something else on this server already uses that port. |
| Tunnel idle timeout | If neither side sends traffic for this long, the connection is closed. Long-lived protocols (WebSocket, SSE) send their own heartbeats and aren't affected — this only catches abandoned connections. Default 120s, 0 disables it. |
| Max simultaneous connections (proxy) |
A global cap on how many connections proxy-core
accepts at once — protects the process itself from running out
of memory or file descriptors. Size it to the resources you've
allocated. Default 10,000, 0 disables the cap.
|
| How does PQ-Proxy learn the visitor's real IP? |
Three modes — pick based on what's in front of PQ-Proxy, if
anything:
|
| Allow backends on private IPs (corporate LAN)? | Default yes. With no, the system actively validates every backend address (resolves its DNS and rejects private/loopback IPs) — an SSRF protection. Only set this to no if you don't fully trust whoever has access to configure domains in your dashboard; for the typical case of one operator running their own instance, the default is fine. |
| Session duration | How long a dashboard login stays valid. Default 24 hours. |
| Enable Prometheus endpoint? | Default no. See Section 11 — Prometheus metrics. |
| Connection log retention | How many days of connection logs to keep before they're purged. Default 30. |
| Webhook URL | Optional. Where to send alerts (license expiry, certificate expiry, backend health). Leave empty to skip webhooks entirely — nothing else below is asked in that case. |
| Webhook HMAC secret |
Only asked if you entered a webhook URL above. Leave empty and
the installer generates one for you automatically —
printed to the terminal once, the same way the
admin token is (see Section 03). Save it
immediately; it's needed to verify the
X-PQProxy-Signature header on webhooks you receive
(see Section 09 — Webhook alerts).
|
| Certificate expiry warning / critical (days) | Thresholds for BYOC certificate expiry alerts. Defaults 30 and 7 days. |
| Check for updates automatically? | Default yes. See Section 14 — Software updates. |
INTERNAL_SERVICE_TOKEN (authenticates
proxy-core to management-api internally)
and CERT_ENCRYPTION_KEY (encrypts BYOC certificate
private keys at rest in the database). Both live in your
.env file.
CERT_ENCRYPTION_KEY along with the rest of
your .env file. If it's lost or regenerated, every
BYOC certificate already stored becomes unreadable — the installer
refuses to regenerate it silently during
--reconfigure for exactly this reason, and will stop
with an explicit error instead.
After it finishes
The installer prints your admin token once — copy it
immediately, it's never shown again. It looks like
pqp_onprem_<random>. You'll use it to log into the
dashboard for the first time (covered in
Section 03 — First login & admin token).
Your dashboard is now reachable at
http://YOUR_SERVER_IP:9090/dashboard.
03 First login & admin token
There's no username/password to set up. The admin token the installer printed is your credential — open the dashboard, paste it in, and you're in.
- Go to
http://YOUR_SERVER_IP:9090/dashboard. - Paste the
pqp_onprem_...token you copied when the installer finished. - You're logged in — a session cookie keeps you signed in for the duration you set during install (default 24 hours).
Rotating the token
Once logged in, you can generate a new admin token at any time from the dashboard (Settings → Rotate admin token) — useful if you suspect the current one was exposed, or as routine hygiene. Rotating it immediately invalidates the old one. Same rule applies: the new token is shown once, copy it right away.
Recovering access
If you never logged in and lost the token, there's no self-service "forgot password" flow through the dashboard or email — by design, this system doesn't depend on any internet connection for account recovery, since it's meant to work fully offline. Instead, run this on the server itself, over SSH, as root:
docker compose exec api pq-proxy-api reset-token
This requires no dashboard access, no active session, and no database knowledge — just SSH to the server. It generates a new token, invalidates the old one immediately, and prints the new token to your terminal in the same format as the very first install. Copy it and log in as usual.
Invalidating all sessions
If you believe someone else may have an active dashboard session (for example, after rotating the token because it leaked), Settings → Invalidate all sessions immediately logs out every other active session — the one you're using right now to click the button stays logged in, so you're never interrupted by your own action.
04 Managing domains
Each domain you add tells PQ-Proxy: "when a visitor connects for this hostname, forward the traffic to this backend." Add domains from Dashboard → Domains → + Add domain.
| Field | What it means |
|---|---|
| Public domain | The hostname your visitors connect to (e.g. api.yourcompany.com). Its DNS must already point to this server. |
| Backend host |
Where PQ-Proxy forwards the traffic — an IP or hostname. Can be
on the same machine (localhost), your private LAN
(10.x, 172.16–31.x, 192.168.x), or reachable over the public
internet.
|
| Backend port | The port your backend listens on. |
| Backend speaks TLS | Whether PQ-Proxy should connect to the backend over TLS (checked) or plain HTTP (unchecked). Independent of where the backend lives — a LAN backend can speak TLS, an internet-facing one can speak plain HTTP. Either way, PQ-Proxy is a raw byte tunnel between visitor and backend — it doesn't parse or inspect what's inside. |
| Backend CA certificate (optional) | Only needed if Backend speaks TLS is checked and the backend's certificate was issued by a private/ internal CA, or is self-signed. A backend with a certificate from a public CA (Let's Encrypt, DigiCert, etc.) is recognized automatically — you don't need to add anything here, even if that backend sits inside your own LAN. This field adds your private CA to the trust list for this domain only — it never replaces the public CA list, and it's never shared with any other domain. |
Editing, disabling, and deleting
Any change to a domain's backend, port, TLS setting, or CA certificate — as well as disabling or deleting it — immediately cuts any connection already in progress for that domain. There's no grace period: a visitor mid-connection when you save the change gets disconnected, and reconnects fresh against whatever is configured now. Re-enabling a domain doesn't cut anything, since nothing was being served for it a moment before.
05 TLS certificates (BYOC)
PQ-Proxy has a single certificate mechanism: bring your own certificate (BYOC). There's no automatic certificate issuance — you upload a certificate and private key for each domain, and PQ-Proxy serves exactly what you gave it.
Upload from Dashboard → Certificates, or from a domain's own settings:
| Field | What it means |
|---|---|
| Certificate (PEM) | The domain's certificate. If your CA issued an intermediate certificate too, you can paste it right after the domain certificate in this same field, or use the separate field below — both work identically. |
| Intermediate certificate (optional) | Your CA's intermediate, if it issued one separately from the domain certificate. |
| Private key (PEM) | The matching private key. RSA and EC keys are supported, in either PKCS8 or their traditional format (BEGIN RSA PRIVATE KEY / BEGIN EC PRIVATE KEY). |
What gets validated on upload
Before anything is saved, PQ-Proxy checks, in order:
- Both fields are valid PEM with the expected markers.
- Both parse as a valid X.509 certificate and a valid private key.
- The certificate and the private key are an actual matching pair — their public keys are compared directly. A mismatched cert/key pair for a domain is a common mistake and is rejected outright, with a clear error, rather than silently stored and failing at connection time.
- The certificate isn't already expired.
- The certificate has already started being valid (its "not before" date isn't in the future).
-
The certificate's SAN (Subject Alternative Name) actually covers
this domain — uploading a certificate issued for a different
domain is rejected, not silently accepted. A single-level wildcard
(
*.example.com) correctly coversa.example.combut nota.b.example.com, matching how wildcard certificates actually work.
Any failure returns a specific error explaining which check failed — nothing partial is ever saved.
Storage
The private key is encrypted at rest with CERT_ENCRYPTION_KEY
(generated automatically during install — see Section 02)
before being stored in the database.
Replacing or removing a certificate
Uploading a new certificate for a domain that already has one simply replaces it — same validation, same effect as a first upload. Deleting a certificate leaves the domain with none until you upload a replacement; nothing is issued automatically to fill the gap.
06 Verifying post-quantum TLS
Before checking anything, know what to expect: not every connection to your domain will be post-quantum. PQ-Proxy negotiates it when the connecting client supports it, and falls back to classical X25519 — standard TLS 1.3 behavior — when it doesn't. Older browsers, some HTTP client libraries, and various automated tools don't support it yet. This is expected, not a misconfiguration.
From a browser
Chrome and Firefox (recent versions) support X25519MLKEM768. Open
DevTools → Security tab (Chrome) while loading your
domain, and check the key exchange group listed for the connection —
it should say X25519Kyber768Draft00 or
X25519MLKEM768 depending on the browser version.
From the command line
With OpenSSL 3.5 or later (which added native ML-KEM support):
openssl s_client -connect your-domain.com:443 -groups X25519MLKEM768 </dev/null
Look for Negotiated TLS1.3 group: in the output — it should show the post-quantum group, not plain X25519.
From your own connection logs
This is the most reliable way to check real traffic, not just your own test connection — every connection PQ-Proxy handles is logged with the key exchange group it actually negotiated, per connection. Go to Dashboard → Metrics, or query the connection log export directly (Section 13) and filter by domain to see the real mix of post-quantum vs. classical connections your visitors are using.
07 License states & limits
Your license has one of four states at any given time, checked when the license file is loaded — at startup, and every 30 minutes afterward. The proxy's behavior depends entirely on this state, not on anything else.
| State | When | Effect on traffic |
|---|---|---|
| VALID | More than 30 days remain until expires_at. |
Normal — all configured domains served. |
| WARNING | 30 days or fewer remain, but not yet expired. | Normal — traffic isn't affected, this is purely informational (dashboard banner + webhook alert, see Section 09). |
| EXPIRED | Past expires_at. |
All traffic stops immediately. There is no grace period — the day it expires, every domain stops being served. Renew and install the new license file to restore service. |
| DOMAIN_LIMIT_EXCEEDED | The license itself is still within its valid dates, but the number of active domains configured exceeds what the license allows. | All traffic stops — not just the domains over the limit, every domain on the instance. Delete domains until you're back within the limit, or install a license with a higher (or no) limit, to restore service. |
max_domains = 0) — DOMAIN_LIMIT_EXCEEDED
can't happen on one. It only applies to licenses issued with a
fixed cap, such as a Trial license (1 domain) or a custom
enterprise license.
Where to check your current state
Dashboard → Overview shows a badge for the current state at a glance, and Dashboard → License shows the full detail — days remaining, domain usage, and tier.
08 Recovering a lost license
If you lost your license.pqp file — deleted it,
migrated servers without backing it up, or never received the
original email — you don't need to contact support. Recovery is
entirely self-service.
Go to onprem.fipsign.dev/recover (linked in the footer of every page on the site) and enter the email you originally used — whether that was for a trial or a paid license.
What you'll get
If that email has any active, non-expired licenses — trial or standard, any number of them — you'll receive a single email with a download link for each one. A license that already expired, or was revoked, won't appear; there's nothing useful to recover there.
This also works months later
The download link never expires and can be reused — recovery isn't
limited to right after you lose it. If you're setting up a
replacement server six months after your original install, the same
flow gets you your license.pqp file again.
09 Webhook alerts
PQ-Proxy can notify you proactively when something needs attention —
no need to check the dashboard yourself. Configure a webhook URL
during install (or later, via --reconfigure), and PQ-Proxy
POSTs a JSON payload to it whenever one of the events below fires.
Events
| Event | Fires when | Frequency |
|---|---|---|
license_expiry_warning |
License has 8–30 days left. | Once per day while it applies. |
license_expiry_critical |
License has 7 or fewer days left. | Once per day while it applies. |
license_expired |
License has passed its expiry date. | Once per day while it applies. |
cert_expiry_warning |
A BYOC certificate on an active domain has 8–30 days left (threshold configurable at install). | Once per day, per certificate, while it applies. |
cert_expiry_critical |
A BYOC certificate has 7 or fewer days left (threshold configurable at install). | Once per day, per certificate, while it applies. |
backend_unhealthy |
A backend has failed 3 consecutive health checks (roughly 3 minutes of sustained downtime). | Immediate on first trigger, then once per day while it remains down. |
backend_recovered |
A backend that had an active backend_unhealthy alert responds successfully again. |
Immediate, once. |
Payload & verifying authenticity
Every payload shares the same envelope — only data changes per event:
{
"event": "cert_expiry_warning",
"timestamp": "2026-09-22T14:30:00Z",
"instance": "your-company-name",
"data": { ...see below... }
}
data for license_expiry_warning, license_expiry_critical, and license_expired:
{
"tenant": "your-company-name",
"tier": "standard",
"expires_at": "2026-10-15T00:00:00Z",
"days_remaining": 5
}
data for cert_expiry_warning and cert_expiry_critical:
{
"domain": "api.yourcompany.com",
"expires_at": "2026-10-01T00:00:00Z",
"days_remaining": 6,
"cert_source": "byoc"
}
data for backend_unhealthy:
{
"domain": "api.yourcompany.com",
"consecutive_failures": 3,
"last_error": "connection refused"
}
data for backend_recovered:
{
"domain": "api.yourcompany.com"
}
The request includes an X-PQProxy-Event: alert header,
and — if you set a webhook secret during install — an
X-PQProxy-Signature: sha256=<hex> header, an
HMAC-SHA256 of the raw request body using that secret. Verify it on
your receiving end to confirm the request genuinely came from your
instance. The timestamp field lets you additionally
reject old, replayed requests — a 5-minute window is a reasonable
default.
--reconfigure keeps
your existing webhook secret by default (it won't silently
rotate). If you didn't save it and need to see it again, entering
a new one when prompted replaces the old one and prints the new
value once — same as the first time. There's no way to display an
existing secret again without changing it.
10 Backend health
Every 60 seconds, PQ-Proxy checks connectivity to each of your active domains' backends — a real TCP connection attempt (and a full TLS handshake, if the domain has Backend speaks TLS enabled). The result is what you see as a badge next to each domain in Dashboard → Domains.
| Badge | Meaning |
|---|---|
| 🟢 Backend healthy | The last check succeeded. |
| 🔴 Backend unhealthy (Nx) | The last check failed; N is how many consecutive checks have failed so far. Hover over the badge for the latency of the last successful check, when it last checked, and the specific error, if any. |
Relationship to webhook alerts
This badge and the backend_unhealthy /
backend_recovered webhooks (Section 09)
come from the exact same underlying check — the badge is a passive
read of the current state, the webhook is what proactively notifies
you when the state changes. A domain can show 🔴 without having
triggered a webhook yet: the webhook only fires after 3 consecutive
failures (roughly 3 minutes of sustained downtime), while the badge
turns red on the very first failed check.
11 Prometheus metrics
If you enabled Prometheus during install (Section 02),
management-api exposes metrics in standard Prometheus
text format at GET /metrics — no authentication, since
this endpoint listens on port 9091, internal to the
Docker network and never mapped to the host by default.
docker-compose.override.yml
next to your compose.yml:
services:
api:
ports:
- "9091:9091"
Then docker compose up -d to apply it. Once exposed, add a scrape target for YOUR_SERVER_IP:9091 in your Prometheus config.
What the raw response looks like
Unlike webhooks, there's no event that gets pushed to you — Prometheus
(or you, with a browser) fetches /metrics whenever it
wants, and gets back the current value of every metric, every time.
A few real lines from the response, so you know what to expect:
# HELP pqproxy_connections_24h Connections in the last 24 hours
# TYPE pqproxy_connections_24h gauge
pqproxy_connections_24h{domain="api.yourcompany.com"} 1543
# HELP pqproxy_cert_expires_days Days until TLS certificate expiry
# TYPE pqproxy_cert_expires_days gauge
pqproxy_cert_expires_days{domain="api.yourcompany.com",source="byoc"} 47
Each metric has a # HELP line (a description) and a
# TYPE line (counter for values that only
go up, like total connections; gauge for values that
can go up or down, like days until expiry), followed by one line per
value. When a metric has more than one label — like
pqproxy_connections_by_algorithm_total, which reports
by both domain and algorithm — you'll see
a separate line for each combination that has data.
Available metrics
Instance
| Metric | What it is |
|---|---|
pqproxy_info{edition,version} | Static instance info — always 1, use the labels. |
pqproxy_domains_active | Number of active domains configured. |
pqproxy_connection_logs_total | Total connection log rows currently in the database. |
pqproxy_license_expires_days{tier,status} | Days until license expiry — negative once it's expired. |
Per domain — traffic (label domain)
| Metric | What it is |
|---|---|
pqproxy_connections_total | Lifetime connections proxied. |
pqproxy_connections_24h | Connections in the last 24 hours. |
pqproxy_bytes_sent_total | Bytes sent client → backend. |
pqproxy_bytes_received_total | Bytes received backend → client. |
Per domain — post-quantum adoption (labels domain, algorithm)
| Metric | What it is |
|---|---|
pqproxy_connections_by_algorithm_total | Lifetime connections, broken down by the negotiated key exchange algorithm. |
pqproxy_connections_by_algorithm_24h | Same breakdown, last 24 hours — the metric to graph "what fraction of my traffic is actually post-quantum right now" (see Section 06). |
Per domain — latency & certificates (label domain)
| Metric | What it is |
|---|---|
pqproxy_tls_handshake_ms_avg | Average TLS handshake time, last 24h. |
pqproxy_backend_connect_ms_avg | Average backend TCP connect time, last 24h. |
pqproxy_cert_expires_days{source} | Days until that domain's certificate expires. |
12 Audit log
Every sensitive action taken on this instance — through the dashboard or the API — is recorded in an audit log: Dashboard → Audit log, paginated (25 per page).
| Action | Logged when |
|---|---|
login | Successful dashboard login. |
login_failed | A login attempt with an invalid token. |
logout | Dashboard logout. |
session_invalidated | Settings → Invalidate all sessions was used (see Section 03). |
token_rotated | The admin token was rotated. |
domain_created | A new domain was added. |
domain_updated | A domain's backend, port, TLS setting, or CA certificate was changed — or it was enabled/disabled. Check the metadata column ({"active": true/false}) to tell a toggle apart from an actual field edit. |
domain_deleted | A domain was deleted. |
domain_limit_reached | Someone tried to add a domain past the license's limit and was rejected. |
cert_uploaded | A BYOC certificate was uploaded (first time or replacement). |
cert_removed | A BYOC certificate was deleted. |
license_verified | The license was manually re-verified from disk. |
license_invalid | A manual re-verification failed (bad signature, corrupted file). |
Exporting
Dashboard → Audit log → Export CSV, with optional
from/to/action filters. Same
row limit and truncation behavior as the connection log export (see
Section 13) — up to 10,000 rows per request, with an
X-Export-Truncated: true header if there's more.
13 Connection logs & export
Every connection PQ-Proxy handles is logged: domain, source address, bytes transferred, negotiated algorithm, and timing (TLS handshake, backend connect, total duration). This is the source of truth behind the Metrics dashboard and behind verifying real post-quantum adoption (Section 06).
Exporting
Dashboard → Metrics → Download CSV, with optional
From/To/Domain filters.
Retention
Connection logs are kept for the number of days set during install (Section 02, default 30). A background worker checks every 24 hours and deletes anything older than that — so purging isn't instant the moment a log crosses the threshold, it happens on the next daily pass. Export anything you need to keep before it ages out.
14 Software updates
There are two independent, unrelated ways this instance can check for a new version — knowing the difference matters, since only one of them actually surfaces anything to you directly.
What you actually see: the dashboard banner
Every time you open Dashboard → Overview, it asks
GET /api/v1/updates/check, which reaches out to
FIPSign's release feed on the spot and compares it to your running
version. If a newer one exists, a banner appears with a link to the
release notes. This is the check you'll actually notice.
A separate background check: logs only, no banner, no webhook
Independently, if Check for updates automatically?
was enabled during install (Section 02), a
background worker performs this same comparison on its own schedule
(default every 24 hours) — but it only ever writes the result to the
container's own logs (docker compose logs api). It does
not show a dashboard banner, and it does not send a webhook alert.
If you want to be notified without having to open the dashboard
yourself, you'd need to watch these logs externally — there's no
built-in push notification for this today.
Updating
Pull the newer images and restart the stack:
docker compose pull && docker compose up -d
Your domains, certificates, and license file are untouched by an update — only the container images change.
15 Reconfiguration
To change any setting after the initial install — a port, the
webhook URL, log retention, and so on — run the installer again with
--reconfigure:
curl -fsSL https://proxyonprem.fipsign.dev/install -o install.sh && bash install.sh --reconfigure
This isn't "start over" — it's closer to editing an existing configuration. Most questions are asked again with your current value pre-filled as the default — press Enter to keep it, or type a new value to change it. A few fields behave differently, worth knowing before you run it:
| Field | Behavior in --reconfigure |
|---|---|
| Database engine (bundled vs. external) |
Not asked at all — silently preserved from
config.toml. Switching between bundled and external
requires editing config.toml by hand and restarting
the stack yourself.
|
| Database password (bundled only) |
Not asked — read back from the existing .env.
Postgres only applies a password the very first time it
initializes an empty data volume, so generating a new one blind
would leave management-api permanently unable to
authenticate against its own database.
|
INTERNAL_SERVICE_TOKEN, CERT_ENCRYPTION_KEY, webhook secret |
Preserved if already present. See Section 02 and
Section 09 — CERT_ENCRYPTION_KEY
specifically refuses to proceed with a silent regeneration,
since that would make every already-stored BYOC certificate
unreadable.
|
| Admin token |
Untouched by --reconfigure entirely — it's never
asked about or regenerated here. To reset it, use
pq-proxy-api reset-token instead (Section 03).
|
.env already exists on disk — not
by whether you passed --reconfigure. Running the
plain installer (no flag) a second time against a server that
already has a working .env preserves them the same
way; the flag mainly controls which questions get asked again.
16 Management API reference
Every endpoint below is served by management-api on
port 9090. All responses share the same envelope:
{"{"}"success": bool, "data": ..., "error": string | null{"}"}.
Click any endpoint to see its exact request and response shape.
Authenticating requests
Every "Main" and "Admin" endpoint below needs either a logged-in
dashboard session (the cookie your browser already holds) or a
Bearer token — the same admin token you log into the
dashboard with, no separate API key or token exchange step.
For scripts and programmatic access, send it as an
Authorization header:
curl https://YOUR_SERVER_IP:9090/api/v1/domains \ -H "Authorization: Bearer pqp_onprem_your_token_here"
Auth — no authentication required
POST /api/v1/auth/token — Log in with the admin token
Request body:
{ "token": "pqp_onprem_..." }
Response (data): sets the session cookie and returns:
{ "authenticated": true }
POST /api/v1/auth/logout — Clear the session cookie
No request body. Clears the session cookie; no meaningful data in the response.
GET /api/v1/auth/me — Whether the current session/token is valid
No request body.
Response (data):
{ "authenticated": true, "instance_name": "your-company-name" }
Main — session cookie or Bearer admin token
GET /api/v1/instance — Instance name and domain count
No request body.
Response (data):
{
"name": "your-company-name",
"edition": "onprem",
"version": "1.0.5",
"license_tier": "standard",
"license_status": "valid",
"max_domains": 0,
"domain_count": 3
}
GET /api/v1/domains — List all domains, active and disabled
No request body. No filtering — unlike the internal endpoint proxy-core polls, this always returns every domain, including disabled ones (the dashboard needs to show them to let you re-enable or delete them).
Response (data, array of):
{
"id": "b7e6...",
"domain": "api.yourcompany.com",
"backend_host": "10.0.1.5",
"backend_port": 8080,
"backend_tls": false,
"active": true,
"created_at": "2026-01-15T00:00:00Z",
"cert_updated_at": "2026-06-01T00:00:00Z",
"backend_ca_cert": null
}
POST /api/v1/domains — Create a domain (Section 04)
Request body:
{
"domain": "api.yourcompany.com",
"backend_host": "10.0.1.5",
"backend_port": 8080,
"backend_tls": false,
"backend_ca_cert": null
}
backend_port and backend_ca_cert are optional; backend_tls defaults to false if omitted. Response shape matches the domain object above.
PUT /api/v1/domains/:id — Update a domain's backend/port/TLS/CA cert
Request body: same shape as create, minus domain itself (it can't be changed after creation — delete and recreate instead).
{
"backend_host": "10.0.1.6",
"backend_port": 8080,
"backend_tls": true,
"backend_ca_cert": "-----BEGIN CERTIFICATE-----..."
}
backend_ca_cert, any
existing custom CA for this domain is removed, not left
untouched.
DELETE /api/v1/domains/:id — Delete a domain
No request body. Immediately cuts any in-progress connection for that domain (Section 04).
PATCH /api/v1/domains/:id/toggle — Enable/disable a domain
No request body — flips the current active value. Disabling cuts in-progress connections; re-enabling does not (Section 04).
GET /api/v1/certificates — List all BYOC certificates
No request body.
Response (data, array of):
{
"domain": "api.yourcompany.com",
"cert_source": "byoc",
"expires_at": "2026-12-01T00:00:00Z",
"obtained_at": "2026-09-01T00:00:00Z",
"chain_complete": true
}
chain_complete: false means the uploaded certificate has no intermediate bundled with it — likely fine for browsers that already cache that CA's intermediate from another site, but may fail for curl or other clients that verify the chain from scratch.
POST /api/v1/certificates/:domain — Upload/replace a certificate (Section 05)
Request body:
{
"cert_pem": "-----BEGIN CERTIFICATE-----...",
"key_pem": "-----BEGIN PRIVATE KEY-----...",
"intermediate_pem": "-----BEGIN CERTIFICATE-----..."
}
intermediate_pem is optional — see Section 05 for the two equivalent ways to supply it. Validated in the order described there before anything is saved.
DELETE /api/v1/certificates/:domain — Delete a certificate
No request body. The domain is left with no certificate until you upload a replacement — nothing is issued automatically.
GET /api/v1/license — Full license detail (Section 07)
No request body.
Response (data):
{
"license_key": "PQP-STD-...",
"tenant": "your-company-name",
"tier": "standard",
"max_domains": 0,
"max_instances": 1,
"features": ["byoc", "prometheus", "webhook_alerts"],
"status": "valid",
"days_remaining": 210,
"expires_at": "2027-06-15T00:00:00Z",
"verified_at": "2026-09-22T10:00:00Z",
"domain_count": 3
}
domain_count counts every domain — active and disabled — same rule create_domain enforces (Section 04).
POST /api/v1/license/verify — Re-verify the license file from disk
No request body. Re-reads and re-verifies license.pqp on the spot, same shape response as GET /api/v1/license.
GET /api/v1/updates/check — Check for a newer version (Section 14)
No request body.
Response (data):
{
"current_version": "1.0.5",
"latest_version": "1.0.6",
"update_available": true,
"release_url": "https://github.com/.../releases/tag/v1.0.6",
"check_enabled": true
}
GET /api/v1/audit-log — Paginated audit log (Section 12)
Query params (all optional): page, per_page (max 100, default 25), action (filter by one of the values from Section 12).
Response (data, array of):
{
"id": "9c2f...",
"action": "domain_updated",
"resource_type": "domain",
"resource_id": "b7e6...",
"metadata": { "active": false },
"ip_addr": "203.0.113.4",
"created_at": "2026-09-22T14:00:00Z"
}
GET /api/v1/audit-log/export — CSV export of the audit log
Query params (all optional): from, to, action. Same 10,000-row cap and X-Export-Truncated header as connection log export (Section 13).
GET /api/v1/metrics/connections — Per-domain connection stats
No request body — this is the dashboard's own JSON metrics, unrelated to the Prometheus endpoint (Section 11).
Response (data, array of):
{
"domain": "api.yourcompany.com",
"connections_24h": 1543,
"connections_7d": 9821,
"connections_30d": 41209,
"pq_connections_24h": 1102,
"pq_connections_7d": 6890,
"pq_connections_30d": 27904,
"bytes_sent_total": 8912340192,
"bytes_recv_total": 40129384021
}
GET /api/v1/metrics/latency — Per-domain TLS/backend latency stats
Query params (optional): algorithm (pq or classical — omit for all).
Response (data, array of):
{
"domain": "api.yourcompany.com",
"tls_p50_ms": 12.4,
"tls_p95_ms": 28.1,
"tls_p99_ms": 45.0,
"backend_p50_ms": 3.2,
"backend_p95_ms": 9.8,
"backend_p99_ms": 15.6,
"sample_count": 1543
}
GET /api/v1/metrics/domain-health — Backend health per domain (Section 10)
No request body — always reads the last check already performed, never triggers a new one on the spot.
Response (data, array of):
{
"domain": "api.yourcompany.com",
"is_healthy": true,
"status": "healthy",
"latency_ms": 42,
"last_checked_at": "2026-09-22T14:00:00Z",
"last_error": null,
"consecutive_failures": 0
}
GET /api/v1/connections/export — CSV export of connection logs (Section 13)
Query params (all optional): from, to, domain. Same 10,000-row cap discussed in Section 13.
Admin — session cookie only, Bearer token not accepted
POST /api/v1/admin/rotate-token — Generate a new admin token
No request body.
Response (data):
{ "token": "pqp_onprem_...", "message": "Save this token — it will not be shown again." }
POST /api/v1/admin/invalidate-sessions — Log out every other active session
No request body. The session making this request is never affected — only every other one (Section 03).
Response (data):
{ "sessions_revoked": 2, "message": "..." }