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

RequirementDetails
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:

PortPurposeExposed 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
ℹ️ If you plan to scrape Prometheus metrics from a monitoring server outside this machine, you'll need to expose port 9091 yourself. Covered in Section 11 — Prometheus metrics.

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 (localhost or 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.

⚠️ PQ-Proxy is a TLS translator, not a full reverse proxy or WAF. It does not inspect, parse, or filter HTTP headers, cache content, or apply access rules — it terminates post-quantum TLS from your visitors and tunnels the raw bytes to your backend. Rate limiting, header-based routing, and content inspection remain the responsibility of your own infrastructure, if you need them.

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.

TrialStandard
Duration14 days365 days
Domains1No limit
Server instances11
PriceFree$499/year
FeaturesIdentical — 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:

  1. Submit your company name and email.
  2. 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.
  3. You're redirected to Dodo Payments to complete payment (card or crypto).
  4. Once payment is confirmed, your license file and download link arrive by email — usually within minutes.
ℹ️ Keep the download link from that email — it doesn't expire and can be reused any time to re-download your 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
ℹ️ If Docker isn't installed yet, the installer installs it for you automatically (via 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.

QuestionWhat 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:
  • Bundled: the engine allows up to 100 simultaneous connections (3 reserved internally by Postgres itself). The installer actively checks your answer against this and won't let you proceed with a value that leaves no margin.
  • External: the installer accepts any number you enter, with no validation — it has no way to know your provider's actual limit. Check your own provider's connection limit (many free tiers cap this quite low) before answering; setting this too high causes intermittent connection failures with no obvious error pointing back to this setting.
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:
  • Direct from the TCP connection — nothing in front, or your firewall/CDN doesn't forward it another way.
  • PROXY protocol — nginx, HAProxy, or an AWS NLB configured with send-proxy/send-proxy-v2.
  • An HTTP header, after TLS — Cloudflare, AWS ALB, or any CDN/load balancer that sets one. You'll then pick the exact header: x-forwarded-for (the de facto standard), cf-connecting-ip (Cloudflare-specific — calculated by Cloudflare itself, so it can't be spoofed by a visitor the way x-forwarded-for sometimes can), or type a custom one.
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.
ℹ️ Two secrets are generated automatically, without asking you anything: 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.
🚫 Back up 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.

  1. Go to http://YOUR_SERVER_IP:9090/dashboard.
  2. Paste the pqp_onprem_... token you copied when the installer finished.
  3. You're logged in — a session cookie keeps you signed in for the duration you set during install (default 24 hours).
🚫 The token is shown once, right after installation, and never again — not in the dashboard, not in the logs, not anywhere. If you lose it before logging in for the first time, see Recovering access below.

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.

FieldWhat 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.

⚠️ A license's domain limit counts every domain you've ever created, active or disabled — not just the ones currently serving traffic. Disabling a domain does not free up a slot; deleting it does. If you're at your limit, delete domains you no longer need rather than just disabling them.

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:

FieldWhat 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:

  1. Both fields are valid PEM with the expected markers.
  2. Both parse as a valid X.509 certificate and a valid private key.
  3. 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.
  4. The certificate isn't already expired.
  5. The certificate has already started being valid (its "not before" date isn't in the future).
  6. 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 covers a.example.com but not a.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.

⚠️ Uploading, replacing, or deleting a certificate immediately cuts any connection in progress for that domain — same behavior as editing the domain itself (see Section 04).

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.

ℹ️ What's guaranteed is the offer, not the outcome per visitor: every connection PQ-Proxy accepts is offered X25519MLKEM768 during the TLS handshake. Whether that particular connection ends up using it depends entirely on what the connecting client supports.

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.

StateWhenEffect 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.
ℹ️ Standard licenses purchased today have no domain limit at all (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.

ℹ️ The response on the recovery page itself is always the same ("check your email"), whether or not anything was actually found for that address — this is intentional, so the form can't be used to probe whether a given email has ever purchased a license.

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

EventFires whenFrequency
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.

ℹ️ Re-running the installer with --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.
⚠️ Delivery is fire-and-forget — if your endpoint is down or returns an error, PQ-Proxy logs it and moves on without retrying. Don't rely on webhooks as your only signal for something critical; cross-check the dashboard periodically too.

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.

BadgeMeaning
🟢 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.
ℹ️ This badge reads the result of the last check already performed — opening the dashboard never triggers a new check on the spot. The dashboard refreshes what it shows every 30 seconds, but the underlying check itself only happens once every 60 seconds.

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.

⚠️ Turning the setting on isn't enough by itself if your Prometheus server runs on a different machine — you also need to expose port 9091 yourself. Add this to a 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

MetricWhat it is
pqproxy_info{edition,version}Static instance info — always 1, use the labels.
pqproxy_domains_activeNumber of active domains configured.
pqproxy_connection_logs_totalTotal 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)

MetricWhat it is
pqproxy_connections_totalLifetime connections proxied.
pqproxy_connections_24hConnections in the last 24 hours.
pqproxy_bytes_sent_totalBytes sent client → backend.
pqproxy_bytes_received_totalBytes received backend → client.

Per domain — post-quantum adoption (labels domain, algorithm)

MetricWhat it is
pqproxy_connections_by_algorithm_totalLifetime connections, broken down by the negotiated key exchange algorithm.
pqproxy_connections_by_algorithm_24hSame 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)

MetricWhat it is
pqproxy_tls_handshake_ms_avgAverage TLS handshake time, last 24h.
pqproxy_backend_connect_ms_avgAverage 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).

ActionLogged when
loginSuccessful dashboard login.
login_failedA login attempt with an invalid token.
logoutDashboard logout.
session_invalidatedSettings → Invalidate all sessions was used (see Section 03).
token_rotatedThe admin token was rotated.
domain_createdA new domain was added.
domain_updatedA 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_deletedA domain was deleted.
domain_limit_reachedSomeone tried to add a domain past the license's limit and was rejected.
cert_uploadedA BYOC certificate was uploaded (first time or replacement).
cert_removedA BYOC certificate was deleted.
license_verifiedThe license was manually re-verified from disk.
license_invalidA 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.

⚠️ Known limitation: each export is capped at 10,000 rows. On a domain with meaningful traffic, a single day can exceed that easily (10,000 connections in 24h is under 7 per minute on average) — a wide date range can silently return only the most recent 10,000, with everything older left out. If your export looks smaller than expected, narrow the date range and export in smaller chunks rather than trusting a single wide request to capture everything.

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:

FieldBehavior 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).
ℹ️ The database password and the three secrets above are actually detected by whether .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"
⚠️ The two Admin endpoints below (rotate-token, invalidate-sessions) only accept the session cookie — a Bearer token gets rejected there even if it's valid everywhere else. You need an actual logged-in browser session for those two.

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-----..."
}
⚠️ This replaces the entire backend configuration — it isn't a partial patch. If you omit 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": "..." }