Configure it once. Query it whenever you want.
Everything Porta reads at startup, everything it re-reads while running, and every endpoint you can ask about its state — written against the running implementation, so field names, defaults and response shapes are the ones the gateway actually uses.
§ 01How Porta is configured#
Porta separates bootstrap from content. A handful of environment variables decide where the gateway listens, where it stores certificates and where its configuration comes from. Everything else — routes, domains, TLS policy — lives in configuration sources that Porta re-reads while it runs.
Environment
Read once at startup. Ports, storage paths, ACME directory, API tokens, and the path to the manifest. Never reloaded — a change means a restart.
Manifest
porta.yaml. Lists the sources for routes, header profiles and TLS. Read once at startup; it decides where config comes from, not what is in it.
Sources
YAML files, Postgres queries, SproutDB queries. Re-read on every reconcile and on demand via the admin API. This is where day-to-day changes happen.
The mental model#
A running gateway holds three independent config dimensions, each fed by an ordered list of sources:
- routes — hostname/path → backend, plus per-route behaviour.
- profiles — named, reusable header transform sets that routes can point at.
- tls — the ACME account, server-wide TLS policy, the domain list, and any private CAs.
Within a dimension, sources are merged in manifest order and the last source wins on a collision. That is what makes the common layout work: a YAML file supplies the stable base, a database supplies the volatile per-customer rows on top.
Each source keeps its own last-known-good result. If your database is unreachable at reconcile time, Porta keeps serving that source's previous rows and leaves every other source untouched — it does not blank your routing table. The drift is reported as stale on /healthz, /monitoring/status and /monitoring/config-sources, so a silent fallback still shows up on your dashboard.
Ports and pipelines#
Porta binds up to four listeners, each with its own middleware pipeline. They never share auth or TLS behaviour.
| Port | Default | TLS | Auth | Serves |
|---|---|---|---|---|
| Public | 443 | Yes (SNI) | — | Proxy traffic, static routes, private-CA root downloads |
| HTTP | 80 | No | — | Optional. ACME HTTP-01 + CA root downloads + 301 to HTTPS |
| Admin | 8081 | No | Bearer, fail-closed | Reload triggers (mutating) |
| Monitoring | 8082 | Yes (same certs) | Bearer (except /healthz) | Read-only state API |
The HTTP listener is opt-in. With GATEWAY_HTTP_REDIRECT_ENABLED=false (the default) port 80 is opened only for the duration of an HTTP-01 validation and closed again afterwards, so nothing occupies it in normal operation.
§ 02A complete configuration#
Three files and a handful of environment variables describe a full gateway: a manifest that says where configuration comes from, a routing file, and a TLS file. The same set applies unchanged however Porta is started.
Configuration directory/etc/porta/
├── porta.yaml # manifest — which sources to read
├── routes.yaml # routing
└── tls.yaml # ACME account, domains, TLS policy
porta.yaml — the manifest
routes:
- type: file
path: /etc/porta/routes.yaml
tls:
- type: file
path: /etc/porta/tls.yaml
routes.yaml
- match:
host: app.example.com
backend:
url: http://127.0.0.1:5001
preserve_host: true
set_x_real_ip: true
tls.yaml
acme:
email: ops@example.com
defaults:
renewal_threshold_days: 30
min_tls_version: "1.2"
domains:
- name: app.example.com
challenge: http-01
Environment
GATEWAY_MANIFEST=/etc/porta/porta.yaml
GATEWAY_CERT_STORAGE=/var/gateway
GATEWAY_CERT_PFX_PASSWORD=change-me
GATEWAY_PUBLIC_PORT=443
GATEWAY_HTTP_REDIRECT_ENABLED=true
GATEWAY_ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org/directory
GATEWAY_ADMIN_API_TOKEN=generate-a-long-random-string
GATEWAY_MONITORING_TOKEN=generate-another-one
GATEWAY_NTFY_TOPIC=porta-alerts-something-unguessable
With that in place the first reconcile runs immediately at startup: Porta validates app.example.com over HTTP-01, obtains a certificate, writes it under GATEWAY_CERT_STORAGE and starts serving. From then on the renewal loop runs on its own, and the gateway can answer for itself:
curl -sk https://app.example.com:8082/healthz | jq .
GATEWAY_MANIFEST and GATEWAY_CERT_STORAGE are the only two locations Porta insists on. The first must be readable at startup; the second must be persistent and writable — it holds every certificate, the ACME account key, the private-CA material, the audit database and the renewal state. Losing it means re-issuing everything and re-onboarding every device that trusts a private CA.
Porta runs per server instance and the configuration above is byte-for-byte identical in every form it runs in — only the paths differ. Nothing in this document changes depending on how the process was started.
§ 03Environment variables#
Bootstrap only. Every variable below is read once at process start; changing one requires a restart. Logging is configured through appsettings.json, not through the environment.
Core#
| Variable | Default | Description |
|---|---|---|
GATEWAY_MANIFEST | /etc/porta/porta.yaml | Path to the deployment manifest. Unreadable or invalid = startup failure, deliberately: there is no silent fallback config. |
GATEWAY_CERT_STORAGE | /var/gateway | Root of all persistent state: certificates, private-CA material, the ACME account key, the audit database, renewal state, DataProtection keys. Back this up; mount it as a volume. |
GATEWAY_CERT_PFX_PASSWORD | empty | Password protecting the stored PFX files (server certs and private-CA keys). |
ACME & DNS#
| Variable | Default | Description |
|---|---|---|
GATEWAY_ACME_DIRECTORY | Let's Encrypt production | ACME directory URL. Point it at the staging directory while testing to avoid burning rate limits. |
GATEWAY_IONOS_API_KEY | empty | IONOS DNS API key. Only used by domains validating over DNS-01 — an HTTP-01-only or private-CA-only deployment can leave it unset. |
GATEWAY_DNS_CHECK_TTL_SECONDS | 300 | How long the monitoring DNS/public-IP snapshot stays fresh before it is re-resolved. Affects /monitoring/certs only, never certificate issuance. |
GATEWAY_MAX_TLS_DOMAIN_DROP_PERCENT | 50 | Safety net for database-backed TLS sources: refuse a merged config whose domain count collapsed by more than this against the last good load. 0 disables it. See § 08. |
Listeners#
| Variable | Default | Description |
|---|---|---|
GATEWAY_PUBLIC_PORT | 443 | The public TLS listener. HTTP/1.1 and HTTP/2. |
GATEWAY_HTTP_REDIRECT_ENABLED | false | true: a permanent port-80 listener answers ACME HTTP-01, serves private-CA root downloads in cleartext, and 301-redirects everything else to HTTPS. false: port 80 is opened only for the duration of a challenge. |
Admin & monitoring APIs#
| Variable | Default | Description |
|---|---|---|
GATEWAY_ADMIN_API_ENABLED | true | Binds the admin listener. Set false to not expose the mutating API at all. |
GATEWAY_ADMIN_API_PORT | 8081 | Admin listener port. Cleartext — keep it on a private network or loopback. |
GATEWAY_ADMIN_API_TOKEN | empty | Bearer token for the admin API. Fail-closed: with no token set, every admin call answers 401. |
GATEWAY_MONITORING_PORT | 8082 | Monitoring listener port. TLS, using the same SNI certificates as the public listener. |
GATEWAY_MONITORING_TOKEN | empty | Bearer token for reads on the monitoring API. Empty means the listener is never bound — the whole monitoring port, /healthz included, is off. |
GATEWAY_MONITORING_WRITE_TOKEN | empty | Separate bearer token for the mutating monitoring endpoint (POST /monitoring/reconcile). Deliberately not the read token: a dashboard token that reaches a browser must not also be able to place ACME orders. Empty = that endpoint answers 401. |
Backend health probes#
| Variable | Default | Description |
|---|---|---|
GATEWAY_HEALTH_PROBES_ENABLED | true | Active probing of backends. The result surfaces as backendHealth on /monitoring/routes. |
GATEWAY_HEALTH_PROBE_INTERVAL_SECONDS | 30 | Probe interval. Values ≤ 0 fall back to the default. |
GATEWAY_HEALTH_PROBE_PATH | / | Path probed on each backend. Any response counts as alive — the probe answers "is the process there", not "is the app happy". |
Alerts#
| Variable | Default | Description |
|---|---|---|
GATEWAY_NTFY_TOPIC | empty | ntfy topic for push alerts (renewal failures, unknown header profiles, certificates approaching expiry). Empty disables alerting. |
GATEWAY_NTFY_SERVER | https://ntfy.sh | ntfy server. Point it at a self-hosted instance if you do not want alerts leaving your network. |
On the public ntfy.sh anyone who knows a topic name can read it. Treat the topic like a secret, or self-host.
§ 04The manifest — porta.yaml#
Three independent sections — routes, profiles, tls — each an ordered list of sources. Order is precedence: later entries win. A section you omit simply contributes nothing.
routes:
- type: file
path: /etc/porta/routes.yaml
tls:
- type: file
path: /etc/porta/tls.yaml
Layered — file base, database on top
routes:
- type: file
path: /etc/porta/routes.yaml # stable infrastructure routes
- type: postgres
connectionEnv: PORTA_PG_CONNECTION # secret by env-var name, never inline
query: |
SELECT fqdn AS match_host,
'http://app-' || tenant_id || ':8080' AS backend_url,
true AS preserve_host
FROM tenants WHERE enabled
profiles:
- type: file
path: /etc/porta/profiles.yaml
tls:
- type: file
path: /etc/porta/tls.yaml # ACME account + defaults + private CAs
- type: postgres
connectionEnv: PORTA_PG_CONNECTION
query: SELECT fqdn AS domain, 'http-01' AS challenge FROM tenants WHERE enabled
Source fields#
| Field | Applies to | Description |
|---|---|---|
type | all required | file, postgres or sproutdb. Anything else is a startup failure. |
path | file required | Path to the YAML file, as the gateway process sees it. |
connection | postgres, sproutdb | Connection string (Postgres) or base URL of the SproutDB server. Required for sproutdb; for postgres it is the alternative to connectionEnv. |
connectionEnv | postgres | Name of an environment variable holding the connection string. Preferred — the credential never lands in the manifest. Missing variable = startup failure. |
database | sproutdb required | SproutDB database name, sent as the X-SproutDB-Database header. |
query | postgres, sproutdb required | The query, verbatim. Result columns are read by name, case-insensitively. |
masterKeyEnv | sproutdb | Name of an environment variable holding the SproutDB API key, sent as X-SproutDB-ApiKey. Missing variable = startup failure. |
Merge and precedence rules#
| Section | Merge key | Rule |
|---|---|---|
routes | (host, path), host case-insensitive | Routes are concatenated in manifest order and deduplicated. On a collision the last source's definition wins; the position in the list stays where it first appeared. |
profiles | profile name | Merged into one name → headers map. Last source wins on a name collision. |
tls | domain name, case-insensitive | Domains are unioned, last wins per name. The ACME account, the defaults block and private_cas are taken from the last source that supplies them — in practice the file base, since database sources contribute domains only. |
At least one TLS source must exist, and one of them must supply the ACME settings if any domain uses the acme issuer. A purely internal deployment — private-CA domains only — needs no ACME account at all and may omit the acme block entirely.
When sources are re-read#
- At startup, before the first request is served, so the gateway never starts with an empty route table.
- On every renewal reconcile — daily at
03:00UTC, plus once immediately at start. - On demand, through the admin API:
/admin/reload-routes,/admin/reload-tls,/admin/reload-certs.
Editing a YAML file does not by itself apply anything — trigger a reload, or wait for the next reconcile.
§ 05Routing — routes.yaml#
A YAML list of routes. Only match.host and a backend are required; every other field is optional and defaults to sane proxy behaviour.
- match:
host: app.example.com
path: /api/ # optional prefix; longest prefix wins
backend:
url: http://127.0.0.1:5001
protocol: http1 # auto (default) | http1 | http2 | http2-prior-knowledge
preserve_host: true # forward the original Host header
set_x_real_ip: true # add X-Real-IP with the client IP
timeout_seconds: 900 # idle timeout toward the backend
max_body_bytes: 17179869184 # request-body limit; 0 = unlimited
block_user_agents: # matching User-Agents get a 404
- '^(curl|Go ).*$'
header_profile: hardened # reusable header set, see § 07
headers:
request_set: { X-Forwarded-By: porta }
request_remove: [Cookie]
response_set: { Cache-Control: no-store }
response_remove: [Server] # nginx `server_tokens off`
Field reference#
| Field | Type | Default | nginx equivalent |
|---|---|---|---|
match.host | string | required | server_name |
match.path | string | all paths | location /prefix |
backend.url | string | required* | proxy_pass |
backend.protocol | enum | auto | proxy_http_version |
backend.static | object | — | root / alias — see § 06 |
preserve_host | bool | false | proxy_set_header Host $host |
set_x_real_ip | bool | false | proxy_set_header X-Real-IP $remote_addr |
timeout_seconds | int | YARP default (~100 s) | proxy_read_timeout |
max_body_bytes | long | Kestrel default (~28 MiB) | client_max_body_size |
block_user_agents | string[] (regex) | — | if ($http_user_agent ~ …) return 404 |
header_profile | string | — | (no equivalent — see § 07) |
headers.request_set | map | — | proxy_set_header |
headers.request_remove | string[] | — | proxy_set_header X ""; |
headers.response_set | map | — | add_header |
headers.response_remove | string[] | — | proxy_hide_header |
* Exactly one of backend.url and backend.static must be present. A route with neither fails the reload.
Matching#
- Host is matched exactly, or as a wildcard (
*.example.com). - Path is an optional prefix. The longest matching prefix wins, so a route on
/api/takes precedence over the same host's catch-all. - Two routes with the same
(host, path)from different sources are not both kept — the later source replaces the earlier one (§ 04).
Backend protocol#
| Value | Behaviour |
|---|---|
auto | YARP default: negotiate down from HTTP/2 — effectively HTTP/1.1 for cleartext backends. Also accepts an empty value. |
http1 | Force HTTP/1.1. Aliases: http/1.1, 1.1. |
http2 | Prefer HTTP/2, fall back to 1.1. Aliases: http/2, 2. |
http2-prior-knowledge | Cleartext HTTP/2 (h2c) with no fallback. Alias: h2c. Use for gRPC backends that do not speak 1.1. |
Handled for you#
No configuration needed for any of these: WebSocket upgrades, X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host. TLS termination happens on the public listener; the backend connection is whatever backend.url says.
block_user_agents takes .NET regular expressions and answers 404 — not 403 — for a match, mirroring the common nginx idiom of pretending the host does not exist. Anchor your patterns; an unanchored pattern matches anywhere in the header.
§ 06Static files#
A route can serve files from a local directory instead of proxying. Replace backend.url with backend.static — the two are mutually exclusive.
- match: { host: files.example.com }
backend:
static:
root: /srv/www/files # document root
fallback: not_found # not_found (default) | index_html (SPA)
- match: # nginx: location /tools/ { alias /opt/tools/; }
host: example.com
path: /tools
backend:
static: { root: /srv/opt/tools }
headers:
response_set: { Access-Control-Allow-Origin: "*" }
| Field | Default | nginx equivalent |
|---|---|---|
backend.static.root required | — | root / alias |
backend.static.fallback | not_found | try_files $uri $uri/ =404 vs. … /index.html |
Static routes match by host and optional path prefix, serve directory index.html, and support conditional requests (ETag / Last-Modified → 304) and range requests. headers.response_set applies; the request-side header transforms do not, since there is no upstream request.
The root is resolved by the gateway process, not by whoever wrote the config. If Porta runs isolated from the filesystem the path refers to, that directory has to be made visible to it first — otherwise the path resolves to nothing and the route answers 404 for everything.
Static routes appear on /monitoring/routes with a synthetic id of the form static:{host}|{path} and a backend of static:{root}. They carry no proxy metrics — request counters and latency stay at zero, because nothing is forwarded.
§ 07Header profiles#
Named, reusable header transform sets. Define a security-header baseline once, point every route at it, and change it in one place.
profiles.yaml — a map of name → transformshardened:
response_set:
Strict-Transport-Security: "max-age=63072000; includeSubDomains"
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
response_remove: [Server, X-Powered-By]
internal-only:
request_set:
X-Internal: "1"
request_remove: [Authorization]
Register the file as a profiles source in the manifest, then reference a profile by name:
- match: { host: app.example.com }
backend: { url: http://127.0.0.1:5001 }
header_profile: hardened
headers:
response_set: { Cache-Control: no-store } # merged on top of the profile
Merge semantics#
- The profile is the base; the route's inline
headersoverride it per key. SettingReferrer-Policyinline replaces just that one header, not the whole profile. - The
*_removelists are unioned, de-duplicated. You cannot un-remove a header the profile removes. - Profile names are matched exactly, case-sensitively, verbatim as written in the map key.
A route referencing a profile that no source defines is discarded, not served without headers — a route that silently loses its security headers is worse than a route that is visibly missing. The drop is logged and raises an ntfy alert (routing / unknown-header-profile). Watch for it after renaming a profile.
§ 08TLS & certificates — tls.yaml#
One file describes the ACME account, the server-wide TLS policy, every domain the gateway terminates, and any installation-local certificate authorities.
acme:
email: ops@example.com # CA account contact; receives expiry/failure notices
use_staging: false # true = Let's Encrypt staging (untrusted, no rate limits)
defaults:
renewal_threshold_days: 30 # renew this many days before expiry
min_tls_version: "1.2" # 1.2 (default, = 1.2+1.3) or 1.3 (1.3 only)
cipher_suites: # optional explicit allow-list; Linux/macOS only
- TLS_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
private_cas: # optional, see § 09
- id: home
name: "Example Home CA"
root_lifetime_days: 3650
leaf_lifetime_days: 47
permitted_suffixes:
- internal
domains:
- name: app.example.com
challenge: http-01 # http-01 (port 80) or dns-01 (default)
- name: "*.example.com" # wildcards require dns-01
- name: app.internal
issuer: private-ca # no ACME — works fully offline
ca: home # optional when exactly one CA is configured
Field reference#
| Field | Type | Default | Notes |
|---|---|---|---|
acme.email | string | required* | Account contact at the CA. *Only required when at least one domain uses the acme issuer. |
acme.use_staging | bool | false | Use the staging directory. Certificates are untrusted by browsers but not rate limited. |
defaults.renewal_threshold_days | int | 30 | Renew when fewer days remain. Private-CA leafs cap this at half the leaf lifetime. |
defaults.min_tls_version | string | 1.2 | ssl_protocols. 1.2 allows 1.2 and 1.3; 1.3 allows 1.3 only. Aliases: tls1.2, tlsv1.3, … |
defaults.cipher_suites | string[] | OS default | ssl_ciphers. .NET TlsCipherSuite names. Applied on Linux/macOS; ignored on Windows, where Schannel policy governs. |
domains[].name | string | required | FQDN or wildcard (*.example.com). |
domains[].challenge | string | dns-01 | dns-01 or http-01. Wildcards must use dns-01. Ignored for private-CA domains. |
domains[].issuer | string | acme | acme (public CA) or private-ca. Aliases: privateca, private_ca. |
domains[].ca | string | the single CA | Which private_cas entry signs this host. Required once more than one CA exists. |
The protocol floor and cipher list are server-wide, not per domain — they are applied to the public listener during the SNI handshake.
Choosing a challenge#
| HTTP-01 | DNS-01 | |
|---|---|---|
| Wildcards | Not possible | Yes |
| Requires | Port 80 reachable from the internet | A supported DNS provider (IONOS built in, more on request) |
| Works with any DNS host | Yes | No |
| Works before the host is public | No | Yes |
Under HTTP-01 with GATEWAY_HTTP_REDIRECT_ENABLED=false, Porta opens port 80 only while a validation is in flight. That keeps the port free for something else the rest of the time, at the cost of a brief bind during renewal.
How certificates are grouped#
- Plain ACME domains are bundled into multi-SAN certificates, grouped by registered domain (eTLD+1, resolved against the Public Suffix List).
- Each group is batched at 100 SANs — Let's Encrypt's per-certificate limit. A registered domain with 250 names becomes three certificates, not one failed order.
- Each wildcard gets its own certificate.
- Private-CA hosts get one certificate per host.
The certId you see on /monitoring/certs is derived from the exact SAN set, so the monitoring view can never disagree with what the renewal loop actually issues.
The renewal loop#
| Behaviour | Value |
|---|---|
| Schedule | Once immediately at startup, then daily at 03:00 UTC |
| Renews when | No certificate exists, the SAN set changed, or fewer than renewal_threshold_days remain |
| Failure backoff | 1 h, 2 h, 4 h, 8 h … capped at 24 h, per certificate |
| Alerting | Escalates as expiry approaches — critical inside 7 days, max priority inside 1 day |
| Removed domains | A certificate no longer required by the config is rotated to history, not deleted mid-flight |
A failing renewal never takes the gateway down. The existing certificate keeps serving until it actually expires, the failure count and last error are visible on /monitoring/certs, and the alert fires long before the browser notices.
Only three things start a pass: process start, the daily cron, and an explicit trigger (the admin API or the monitoring trigger). Nothing watches your configuration — editing a TLS source does not apply anything on its own. Trigger a reconcile, or wait for 03:00.
Passes are serialized: a second trigger while one is running waits rather than starting a parallel pass. Two overlapping passes would place duplicate ACME orders for the same SAN set and burn Let's Encrypt's five-duplicate-certificates-per-week budget.
The domain-collapse guard#
A reconcile retires certificates the config no longer asks for. With a database TLS source that is a sharp edge: one row per domain, and zero rows is a valid load, not an error — so a broken WHERE clause, a failed migration or a mass enabled = false would quietly retire the fleet.
So a merged config whose domain count dropped by more than GATEWAY_MAX_TLS_DOMAIN_DROP_PERCENT (default 50) against the last good load is refused. The gateway keeps serving the previous config, the source shows up as stale on /monitoring/config-sources, and nothing is destroyed. The guard needs a baseline from the running process, so a cold start never trips it, and lists shorter than four domains are exempt.
§ 09Private CA — browser-trusted HTTPS for internal hosts#
An installation-local CA gives hosts that have no public domain, no public DNS and no internet access real HTTPS — which means a real Secure Context: service workers, PWA install, Web Push, passkeys. No ACME, no rate limits, works fully offline.
private_cas:
- id: home # used in storage paths and download URLs; [A-Za-z0-9._-]
name: "Example Home CA" # shown on devices after installing the root
root_lifetime_days: 3650 # rotating the root re-onboards every device
leaf_lifetime_days: 47 # server certs, auto-rotated; clamped to 825
permitted_suffixes:
- internal # .internal is the ICANN-reserved private-use TLD
domains:
- name: app.internal
issuer: private-ca
ca: home
| Field | Type | Default | Notes |
|---|---|---|---|
id | string | required | Identifier used in storage and in the download URLs. |
name | string | = id | Display name in device certificate settings and in the iOS profile. |
root_lifetime_days | int | 3650 | Root validity. |
leaf_lifetime_days | int | 47 | Server-certificate validity; clamped to 825 days (Apple's cap for privately-trusted certs). |
permitted_suffixes | string[] | required | DNS suffixes this CA may issue for. Baked into the root's critical Name Constraints. |
The suffix list is written into the root certificate as X.509 Name Constraints, so it is what mathematically bounds the CA — a device that trusts this root will refuse anything outside it. Extending the list means a new root and re-onboarding every device. Pick it deliberately.
A suffix must not itself be a public suffix (checked against the Public Suffix List). internal or an owned subdomain like intern.example.com (split-horizon DNS) is fine; com or co.uk is rejected.
Renewal for private-CA leafs uses the same loop as ACME, with one adjustment: the effective threshold is capped at half the leaf lifetime. With the default 47-day leafs and a 30-day global threshold, the effective threshold becomes 23 days — otherwise every reconcile would re-issue.
Device onboarding endpoints#
The root certificate — never the key — is served unauthenticated, because a fresh device has no trust yet and could not authenticate anyway. Integrity is verified out of band, by comparing the SHA-256 fingerprint from /monitoring/private-cas.
| Path | Content type | For |
|---|---|---|
/.porta/ca/{id}/root.crt | application/x-x509-ca-cert | DER — Android, Windows, macOS, Linux |
/.porta/ca/{id}/root.pem | application/x-pem-file | PEM — curl, OpenSSL, Linux trust stores |
/.porta/ca/{id}/root.mobileconfig | application/x-apple-aspen-config | iOS/iPadOS configuration profile |
These are mounted on the public HTTPS listener and — when GATEWAY_HTTP_REDIRECT_ENABLED is on — on cleartext port 80 as well, so a device that does not trust the CA yet can fetch the root without walking through a certificate interstitial. An unknown or malformed {id} answers 404.
# fetch and verify before installing
curl -o root.pem http://app.internal/.porta/ca/home/root.pem
openssl x509 -in root.pem -noout -fingerprint -sha256
# compare against the gateway's own view
curl -s -H "Authorization: Bearer $TOKEN" \
https://app.internal:8082/monitoring/private-cas | jq -r '.[].fingerprintSha256'
On iOS the profile still has to be trusted manually under Settings → General → About → Certificate Trust Settings after installation — that toggle is Apple's, and no profile can set it for you.
The private CA publishes no CRL and runs no OCSP responder. If a root is compromised the recovery path is: delete it, create a new CA, re-onboard every device. Keep GATEWAY_CERT_PFX_PASSWORD real and the storage volume protected — the CA private key lives there, encrypted, and is not exportable through any API.
§ 10Postgres as a config source#
The Postgres source does not require dedicated tables. It runs your query verbatim and reads the result columns by name, case-insensitively — so you write a SELECT that aliases your existing schema onto the expected names. Joins, CTEs and WHERE are all fine.
1. Query parameters are not supported — every value must be literal in the query text. Never build the query from untrusted input.
2. jsonb/json columns arrive as text and are then JSON-deserialised. Header maps are string → string, so cast every value in a headers_*_set object to ::text, or deserialisation throws.
Routing query columns#
| Column | Type | How to produce it |
|---|---|---|
match_host required | text | Alias your hostname column. |
match_path | text | Optional prefix; omit or NULL for all paths. |
backend_url required | text | e.g. 'http://' || host || ':' || port |
backend_protocol | text | auto / http1 / http2 / http2-prior-knowledge |
preserve_host, set_x_real_ip | bool | Direct alias. |
max_body_bytes | bigint | Direct alias. |
timeout_seconds | int | Direct alias. |
header_profile | text | Name of a profile from a profiles source. |
headers_request_set, headers_response_set | jsonb map | jsonb_build_object('X-Max', limit::text) — values must be text. |
headers_request_remove, headers_response_remove | jsonb array | jsonb_build_array('Cookie') or to_jsonb(text_array) |
block_user_agents | jsonb array | to_jsonb(array['^Go .*$']) |
Static-file routes are not expressible from a database source — backend_url is required. Keep static routes in the file base.
SELECT
fqdn AS match_host,
'http://127.0.0.1:' || port AS backend_url,
'http1' AS backend_protocol,
true AS preserve_host,
true AS set_x_real_ip,
900 AS timeout_seconds,
CASE WHEN big_uploads THEN 17179869184 END AS max_body_bytes,
CASE WHEN fqdn = 'registry.example.com'
THEN jsonb_build_array('^(docker/1\.|Go ).*$') END AS block_user_agents,
jsonb_build_array('Server') AS headers_response_remove
FROM sites
WHERE enabled
TLS query columns#
A domains-only source: one row per domain. The ACME account, the defaults block and any private CAs always come from the file base — a database source never supplies them. Zero rows is valid and simply contributes nothing.
| Column | Type | Notes |
|---|---|---|
domain required | text | FQDN. Blank/NULL rows are skipped, not an error. |
challenge | text | dns-01 (default when NULL) or http-01. |
SELECT fqdn AS domain, 'http-01' AS challenge
FROM sites
WHERE tls_enabled
Header-profile query columns#
Fully relational — one row per header directive, no JSON needed. Rows are grouped by name.
| Column | Notes |
|---|---|
name required | Profile name, as referenced by header_profile. |
type required | request_set, request_remove, response_set or response_remove. Anything else fails the load. |
header required | The HTTP header name. |
value | The header value. Ignored for the *_remove types; NULL becomes an empty string. |
SELECT profile AS name, kind AS type, header_name AS header, header_value AS value
FROM header_rules
Manifest wiring
routes:
- type: file
path: /etc/porta/routes.yaml
- type: postgres
connectionEnv: PORTA_PG_CONNECTION
query: "SELECT fqdn AS match_host, 'http://127.0.0.1:'||port AS backend_url FROM sites WHERE enabled"
tls:
- type: file
path: /etc/porta/tls.yaml
- type: postgres
connectionEnv: PORTA_PG_CONNECTION
query: "SELECT fqdn AS domain, 'http-01' AS challenge FROM sites WHERE tls_enabled"
With PORTA_PG_CONNECTION in the environment, e.g. Host=db;Port=5432;Database=porta;Username=porta;Password=secret. Give that user SELECT and nothing else — Porta never writes to your database.
§ 11SproutDB as a config source#
Same idea as Postgres, over REST. Porta posts the query text to POST {connection}/sproutdb/query with the database and API key as headers, and reads the returned rows by column name.
POST https://sprout.example.com/sproutdb/query
X-SproutDB-Database: porta
X-SproutDB-ApiKey: {masterKeyEnv value}
Content-Type: text/plain
<your query text>
SproutDB has no JSON column type, so the map- and list-valued columns are stored as JSON strings and carry a _json suffix. Everything else matches the Postgres column names.
Routing columns#
| Column | Type | Notes |
|---|---|---|
match_host required | string | |
match_path | string | Optional prefix. |
backend_url required | string | |
backend_protocol | string | auto / http1 / http2 / http2-prior-knowledge |
preserve_host, set_x_real_ip | bool | |
max_body_bytes | long | |
timeout_seconds | int | |
header_profile | string | |
headers_request_set_json, headers_response_set_json | string | JSON object, string values only. |
headers_request_remove_json, headers_response_remove_json | string | JSON array of header names. |
block_user_agents_json | string | JSON array of regex patterns. |
TLS and profile columns#
Identical to Postgres: TLS is one row per domain with domain and optional challenge; header profiles are one row per directive with name, type, header, value.
routes:
- type: file
path: /etc/porta/routes.yaml
- type: sproutdb
connection: https://sprout.example.com
database: porta
masterKeyEnv: PORTA_SPROUT_KEY
query: "from sites where enabled = true select fqdn as match_host, backend as backend_url"
A non-2xx response, or a SproutDB error envelope, fails that source's load — which means last-known-good takes over and the source is reported stale. An empty result set is not an error: it contributes zero rows, and for routing that means those routes disappear on the next reconcile. Guard your query accordingly.
§ 12Querying Porta — the monitoring API#
A read-only HTTP API on the monitoring port. Nothing here mutates state, so it is safe to poll from a dashboard, a uptime checker, or your own tooling. The Porta Monitor desktop client is simply one consumer of these same endpoints.
Connecting#
- Base URL:
https://{host}:8082— TLS, using the same SNI certificates as the public listener. Connect with a hostname the gateway holds a certificate for; an IP address will not match. - Auth:
Authorization: Bearer {GATEWAY_MONITORING_TOKEN}, compared in constant time. Clients that cannot set headers may pass?access_token=…instead. - Exception:
/healthzneeds no token, so an external uptime monitor can poll it directly. - CORS: any origin is allowed, with the preflight answered before the token check — every endpoint except
/healthzis token-gated regardless, so a browser client from another origin works out of the box. - Off switch: with
GATEWAY_MONITORING_TOKENempty the listener is never bound at all. No token, no port.
export PORTA=https://gw.example.com:8082
export TOKEN=your-monitoring-token
curl -s -H "Authorization: Bearer $TOKEN" $PORTA/monitoring/status | jq .
| Endpoint | Answers |
|---|---|
GET /healthz | Is this gateway serving? (no auth) |
GET /monitoring/status | One-line summary: uptime, counts, config drift |
GET /monitoring/certs | Every certificate, its expiry, its renewal state, its DNS reality check |
GET /monitoring/routes | Every route with traffic counters and backend health |
GET /monitoring/routes/detail | One route: status-code breakdown, per-minute buckets, recent errors |
GET /monitoring/reload-history | What was reloaded, when, by whom, and whether it worked |
GET /monitoring/audit | The audit log, filterable |
GET /monitoring/acme-account | Whether the ACME account is registered |
GET /monitoring/private-cas | Private CA roots and their fingerprints |
GET /monitoring/config-sources | Per-source load state — where config drift comes from |
POST /monitoring/reconcile | Start a certificate reconcile now (write token) |
Three states, deliberately mapped onto two status codes.
| status | HTTP | Meaning |
|---|---|---|
healthy | 200 | Every check is ok. |
degraded | 200 | Serving correctly, but something needs attention — currently only config_sources: stale. |
unhealthy | 503 | A check failed in a way that affects service. |
degraded answers 200 on purpose: it is not an outage, and paging someone at 3 a.m. for a stale config source trains people to ignore the pager. The reason is always named in checks.
{
"status": "degraded",
"checks": {
"cert_cache": "ok",
"renewal_scheduler": "ok",
"sqlite": "ok",
"config_sources": "stale"
}
}
| Check | Values | What it means |
|---|---|---|
cert_cache | ok · no_active_cert | Whether a usable fallback certificate is loaded. Without one, TLS handshakes for unknown SNI fail. |
renewal_scheduler | ok · stale · failing · stalled | Reads the last pass's outcome, not just its age. failing: the last pass threw or was cancelled. stale: it finished but some cert group failed. stalled: no pass in 25 hours. A freshly started process reports ok. |
sqlite | ok · unreachable | The audit database answers a trivial query. |
config_sources | ok · stale · unavailable | Aggregated worst state across all manifest sources. stale degrades, unavailable is unhealthy. |
The cheapest call. Poll this; fetch detail only when something changes.
{
"status": "running",
"now": "2026-08-23T09:14:02.115+00:00",
"startedAt": "2026-08-19T22:03:44.901+00:00",
"certCount": 12,
"routeCount": 27,
"configStale": false
}
configStale is the summary flag for "the running config no longer matches the configured one". When it is true, /monitoring/config-sources tells you which source and why.
lastReconcile answers the question nothing else can: did the renewal loop run, and did it get anywhere. It is published even when the pass threw or was cancelled — an aborted cycle that leaves no trace is precisely how a missing certificate stays invisible.
{
"status": "running",
"certCount": 12,
"routeCount": 27,
"configStale": false,
"reconcileInProgress": false,
"lastReconcile": {
"startedAt": "2026-08-23T03:00:04.118+00:00",
"completedAt": "2026-08-23T03:02:41.006+00:00",
"trigger": "cron",
"outcome": "partial",
"certsIssued": 2,
"certsUpToDate": 38,
"certsFailed": 1,
"certsSkippedBackoff": 0,
"error": "new.example.com: order failed: DNS problem"
}
}
| Field | Values / notes |
|---|---|
trigger | startup · cron · admin_api · monitoring_api |
outcome | running · succeeded · partial (ran to the end, some cert failed) · failed (threw; later cert groups were never attempted) · cancelled |
reconcileInProgress | A pass can legitimately take minutes. Without this, a pending certificate is ambiguous between "being worked on right now" and "nothing will ever happen". |
Every certificate group Porta is responsible for — configured ones (even before they are issued) and orphans still on disk.
{
"certs": [
{
"certId": "a3f1c8…",
"status": "active",
"domains": [
{
"name": "app.example.com",
"challenge": "http-01",
"dns": {
"a": ["203.0.113.10"],
"aaaa": [],
"cname": null,
"pointsAtGateway": "yes",
"checkedAt": "2026-08-23T09:09:41.002+00:00",
"error": null
}
}
],
"notBefore": "2026-08-01T00:00:00+00:00",
"notAfter": "2026-10-30T00:00:00+00:00",
"issuedAt": "2026-08-01T03:00:12.441+00:00",
"nextRenewalAttempt": null,
"lastAttempt": "2026-08-23T03:00:07.118+00:00",
"consecutiveFailures": 0,
"lastError": null,
"history": [
{ "timestamp": "2026-08-01T03:00:12.441+00:00",
"eventName": "cert_issued", "severity": "info", "detailsJson": "{…}" }
]
}
],
"gatewayIp": { "ipv4": "203.0.113.10", "ipv6": null, "checkedAt": "2026-08-23T09:09:40.500+00:00" },
"configError": null
}
| Field | Notes |
|---|---|
certId | Derived from the exact SAN set. Stable as long as the domain group is. |
status | active · expiring · failed · pending · orphaned — see § 14. |
domains[].challenge | http-01, dns-01, private-ca, or null for an orphan. |
domains[].issuer | acme or private-ca; domains[].caId names the issuing private CA. |
domains[].servedFromCache | Whether this process can actually complete a handshake for the name right now. The field to check when a certificate looks fine but the site does not load: the cert list reads the filesystem while handshakes are answered from an in-memory cache, so false next to a valid notAfter means the cert exists on disk but is not being served — trigger a reconcile or /admin/reload-certs. |
waitReason | backoff · up_to_date · not_attempted · in_progress. not_attempted separates a genuinely new domain from one a repeatedly aborting reconcile never reaches — the two used to be an identical pending row. |
lastConsidered | When a pass last looked at this group at all, including deciding it was up to date or inside its backoff. null means no pass ever got here. |
recentSniMisses | Top-level on the response: hostnames whose TLS handshake was rejected for want of a certificate, with counts. A rejected handshake never becomes an HTTP request, so no route metric ever sees it — this is its only trace, and the fastest path from "the site is down" to "the cert never landed". |
domains[].dns | Public A/AAAA/CNAME resolution, cached for GATEWAY_DNS_CHECK_TTL_SECONDS. ACME domains only — private-CA hosts are internal by construction and are never sent to a public resolver, so their dns is null. |
pointsAtGateway | yes · no · unknown. Answers "does this name resolve to this gateway from outside", which is an ACME precondition. |
| date fields | null until a certificate has actually been issued. |
consecutiveFailures, lastError, nextRenewalAttempt | Mirror the persisted renewal state, including the exponential backoff. |
history | Up to 10 recent audit events matching this certificate's domains. |
configError | Non-null when the TLS config itself could not be loaded. The response then lists active certificates only, rather than returning an error — a broken config must not blank the page. |
Every route in the running table, with counters accumulated since process start.
[
{
"routeId": "b71e0d…",
"host": "app.example.com",
"pathPrefix": null,
"backendUrl": "http://127.0.0.1:5001",
"requestsTotal": 184223,
"requestsFailed": 91,
"bytesIn": 40113882,
"bytesOut": 991244102,
"latency": { "p50": 4.1, "p90": 11.8, "p99": 52.3, "sampleCount": 4096 },
"backendHealth": "healthy",
"backendHealthSince": "2026-08-19T22:04:02.771+00:00"
}
]
Latency is in milliseconds, computed over a rolling sample window. Static routes appear with a static: route id and zeroed counters — nothing is forwarded, so there is nothing to count. backendHealth is null when active probing is disabled.
The id is a query parameter rather than a path segment because synthetic static-route ids contain | and :. An unknown id answers 404.
{
"routeId": "b71e0d…",
"host": "app.example.com",
"pathPrefix": null,
"backendUrl": "http://127.0.0.1:5001",
"requestsTotal": 184223,
"requestsFailed": 91,
"bytesIn": 40113882,
"bytesOut": 991244102,
"latency": { "p50": 4.1, "p90": 11.8, "p99": 52.3, "sampleCount": 4096 },
"backendHealth": "healthy",
"backendHealthSince": "2026-08-19T22:04:02.771+00:00",
"statusCodes": [ { "code": 200, "count": 183900 }, { "code": 502, "count": 91 } ],
"buckets": [ { "minuteUtc": "2026-08-23T09:13:00+00:00", "total": 214, "failed": 0 } ],
"recentErrors": [
{ "timestamp": "2026-08-23T08:41:19.204+00:00", "method": "POST", "path": "/api/import",
"statusCode": 502, "forwarderError": "RequestTimedOut",
"exceptionType": "TaskCanceledException", "exceptionMessage": "…" }
]
}
buckets is a per-minute rolling window — enough to draw a sparkline without keeping a time-series database around.
limit defaults to 50. Newest first.
[
{ "id": 412, "timestamp": "2026-08-23T09:02:07.118+00:00",
"what": "tls", "trigger": "admin_api", "success": true, "errorMessage": null },
{ "id": 411, "timestamp": "2026-08-22T14:22:51.900+00:00",
"what": "routes", "trigger": "admin_api", "success": false,
"errorMessage": "Route 'app.example.com' has neither a backend nor a static root." }
]
what is routes, tls or certs; trigger records who asked. diffSummary carries the tally of the pass. This history covers explicitly triggered reloads — the scheduled daily reconcile publishes its result on /monitoring/status instead. It is the first place to look after an edit that "did not take".
Two things worth knowing: a reload that was cancelled (client disconnect, shutdown) is recorded here as a failure rather than vanishing, and success is false when a TLS pass ran to the end but some cert group failed — a green row never implies "the change landed" on its own.
The persisted audit log, newest first. limit defaults to 100; category and severity are optional exact-match filters.
[
{ "id": 9182, "timestamp": "2026-08-23T03:00:12.441+00:00",
"category": "acme", "severity": "info", "eventName": "cert_issued",
"domain": "app.example.com,api.example.com", "detailsJson": "{\"sans\":2}" }
]
| Field | Values |
|---|---|
category | acme, tls, private-ca |
severity | info, error |
eventName | renewal_started, cert_issued, renewal_failed, cert_retired, account_registered, root_created, … |
domain | Comma-joined list for multi-SAN certificates; null for events not tied to a domain. |
detailsJson | Event-specific JSON, as a string. Treat the shape as informational, not as a contract. |
{ "accountUrl": "https://acme-v02.api.letsencrypt.org/acme/acct/1234567", "isRegistered": true }
isRegistered: false on a deployment that has ACME domains means no order has been placed yet — expected before the first reconcile, worth investigating afterwards.
The list device onboarding is verified against. Note what is not here: the key.
[
{
"id": "home",
"name": "Example Home CA",
"fingerprintSha256": "9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08",
"notBefore": "2026-02-11T18:22:03+00:00",
"notAfter": "2036-02-09T18:22:03+00:00",
"createdAt": "2026-02-11T18:22:04.118+00:00",
"permittedSuffixes": ["internal"]
}
]
One entry per manifest source, identified by section and position. This is the endpoint that turns a silent last-known-good fallback into something you can see.
[
{ "kind": "routes", "index": 0, "state": "ok",
"error": null, "failingSince": null, "lastSuccessAt": "2026-08-23T03:00:05.221+00:00" },
{ "kind": "routes", "index": 1, "state": "stale",
"error": "Connection refused (db:5432)",
"failingSince": "2026-08-22T19:41:03.775+00:00",
"lastSuccessAt": "2026-08-22T19:11:02.400+00:00" }
]
| Field | Notes |
|---|---|
kind | routes, profiles or tls — the manifest section. |
index | Zero-based position within that section, matching the manifest order. |
state | ok · stale · unavailable. |
failingSince | Timestamp of the first failure in the current streak — how long the drift has lasted. |
lastSuccessAt | When this source last loaded cleanly. That is the content currently being served. |
A source only appears once it has been attempted at least once.
Starts a certificate reconcile immediately instead of waiting for 03:00. This is what a "renew now" button in a monitoring client calls — it lives on the monitoring listener, so the admin port stays local and uninvolved.
Authenticated with GATEWAY_MONITORING_WRITE_TOKEN, not the read token. Body is optional:
curl -sk -X POST "$PORTA/monitoring/reconcile" \
-H "Authorization: Bearer $WRITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"force": true}'
{ "started": true, "trigger": "monitoring_api", "force": true, "certId": null }
| Field | Default | Meaning |
|---|---|---|
force | false | Clear the retry backoff first. Without it, a trigger inside a backoff window (up to 24 h after repeated failures) is a legitimate no-op — which reads as "the button does nothing". |
certId | all | Reconcile only this cert group, from /monitoring/certs. A narrowed pass does not retire anything, since it deliberately looked at one group only. |
It answers 202 and runs the pass in the background. That is deliberate: a reconcile legitimately takes minutes, and a synchronous trigger would recreate the failure mode it exists to fix — a client timeout killing the work halfway through, leaving a started-but-never-finished certificate and no error anywhere. Poll /monitoring/status for progress.
If a pass is already running you get 202 with started: false and the current lastReconcile — not an error, because a reconcile is what you asked for and one is happening.
The background work runs on the process lifetime token, so it survives the response but not a shutdown.
Polling patterns#
- Uptime monitor:
/healthzevery 30–60 s, no token. Alert on503; surfacedegradedwithout paging. - Dashboard:
/monitoring/statuson a short interval, and fetch the heavier endpoints only whencertCount,routeCountorconfigStalechanges. - Certificate watch:
/monitoring/certsa few times a day is plenty — the renewal loop runs daily. Alert on anystatusoffailedorexpiring, or anyconsecutiveFailures > 0. - Config drift: alert on
configStale: truepersisting past one reconcile cycle.
The DNS snapshot behind /monitoring/certs is cached, so polling it harder does not resolve harder — it just returns the same snapshot.
§ 13The admin API#
Three mutating endpoints, all of them idempotent triggers: re-read configuration now instead of waiting for the next reconcile. Everything is bearer-authenticated and fail-closed — with no GATEWAY_ADMIN_API_TOKEN set, every call answers 401.
The admin listener does not do TLS. Keep it on loopback or a private network and do not expose the port. If you need it across a network, put it behind something that terminates TLS.
Re-reads every routing source and rebuilds the proxy table. Answers {"reloaded":"routes"}, or 500 with the error detail if a source is invalid.
Re-reads the TLS configuration and runs a full reconcile: issue what is missing, renew what is due, retire what is no longer configured. This is the call to make after adding a domain.
Reloads the certificate cache from storage without contacting any CA. Use it after replacing certificate files on disk out of band.
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:8081/admin/reload-tls
Every call is recorded in the reload history with trigger: "admin_api", success or failure — so /monitoring/reload-history is the audit trail for anything anyone triggered by hand.
§ 14Status vocabulary#
Every status string Porta can hand back, in one place, with what you should do about it.
Certificate status#
| Value | Meaning | Action |
|---|---|---|
active | Issued, valid, more than renewal_threshold_days remaining. | None. |
expiring | Inside the renewal window. Normal for a day or two around renewal. | Watch. If it persists past a reconcile, check lastError. |
failed | At least one consecutive renewal failure. The existing certificate keeps serving until it expires. | Read lastError; check DNS, port 80 reachability, or the DNS API key. |
pending | Configured but never issued — normal between adding a domain and the next reconcile. | Trigger /admin/reload-tls if you do not want to wait. |
orphaned | A certificate on disk that the current config no longer asks for. | Expected after removing a domain. It is rotated to history, not served. |
Config source state#
| Value | Meaning | Effect on /healthz |
|---|---|---|
ok | Last load succeeded. | healthy |
stale | Load failed; the source's last-known-good is being served. Running config ≠ configured config. | degraded (200) |
unavailable | Load failed and this source has never succeeded — its whole contribution is missing. | unhealthy (503) |
DNS verdict#
| Value | Meaning |
|---|---|
yes | The name resolves to this gateway's public address. HTTP-01 can work. |
no | It resolves somewhere else. An HTTP-01 renewal will fail; DNS-01 may still be fine. |
unknown | Resolution failed or the gateway's own public IP could not be determined. Check dns.error. |
§ 15Recipes#
The tasks that actually come up, end to end.
Coming from nginx#
| nginx | Porta |
|---|---|
server_name | match.host |
location /prefix | match.path |
proxy_pass | backend.url |
proxy_http_version | backend.protocol |
proxy_set_header Host $host | preserve_host: true |
proxy_set_header X-Real-IP $remote_addr | set_x_real_ip: true |
proxy_read_timeout | timeout_seconds |
client_max_body_size | max_body_bytes |
server_tokens off | headers.response_remove: [Server] |
add_header / proxy_hide_header | headers.response_set / response_remove |
root / alias | backend.static.root |
try_files $uri /index.html | backend.static.fallback: index_html |
ssl_protocols | defaults.min_tls_version |
ssl_ciphers | defaults.cipher_suites |
if ($http_user_agent ~ …) return 404 | block_user_agents |
| certbot cron job | — built in |
WebSocket upgrade blocks and the whole X-Forwarded-* dance have no equivalent because they need no configuration.
Add a domain#
# 1. routes.yaml
- match: { host: new.example.com }
backend: { url: http://127.0.0.1:5005 }
preserve_host: true
# 2. tls.yaml
domains:
- name: new.example.com
challenge: http-01
# 3. apply now instead of waiting for 03:00 UTC — either the local admin API…
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:8081/admin/reload-tls
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:8081/admin/reload-routes
# …or, from a monitoring client over the network:
curl -sk -X POST -H "Authorization: Bearer $WRITE_TOKEN" $PORTA/monitoring/reconcile
# 4. confirm
curl -s -H "Authorization: Bearer $TOKEN" $PORTA/monitoring/certs \
| jq '.certs[] | select(.domains[].name == "new.example.com") | {status, notAfter, lastError}'
Point DNS at the gateway before reloading if you use HTTP-01 — the challenge needs the name to resolve to this host. pointsAtGateway on /monitoring/certs tells you whether it does.
A wildcard certificate#
# tls.yaml — wildcards require dns-01, which is the default
domains:
- name: "*.example.com"
- name: example.com # the apex is not covered by the wildcard
Set GATEWAY_IONOS_API_KEY and make sure the zone is hosted at a supported provider. The wildcard gets its own certificate; the apex joins the multi-SAN group.
An internal host with real HTTPS#
# tls.yaml
private_cas:
- id: home
name: "Acme Home CA"
permitted_suffixes: [internal]
domains:
- name: nas.internal
issuer: private-ca
# routes.yaml
- match: { host: nas.internal }
backend: { url: http://192.168.1.20:5000 }
preserve_host: true
Then install the root once per device from /.porta/ca/home/root.crt (or .mobileconfig on iOS), after checking the fingerprint against /monitoring/private-cas. No internet, no public DNS, no ACME involved — and the browser treats https://nas.internal as a Secure Context.
Host an SPA and its API on one host#
- match: { host: app.example.com, path: /api }
backend: { url: http://127.0.0.1:5001 }
preserve_host: true
- match: { host: app.example.com }
backend:
static:
root: /srv/www/app
fallback: index_html # client-side routing
Longest prefix wins, so /api/* proxies and everything else falls through to the static root — as long as /srv/www/app is a path the gateway process can actually read.
Large uploads, slow backends#
- match: { host: registry.example.com }
backend: { url: http://127.0.0.1:5000 }
preserve_host: true
max_body_bytes: 0 # unlimited
timeout_seconds: 3600
max_body_bytes: 0 removes the limit entirely; omitting the field leaves Kestrel's ~28 MiB default in place. timeout_seconds is an idle timeout, not a total request budget.
Multi-tenant, database driven#
Keep the infrastructure routes in YAML and let the tenant table drive the rest. Adding a customer becomes an INSERT plus one reload call — no file edits, no redeploy.
routes:
- type: file
path: /etc/porta/routes.yaml
- type: postgres
connectionEnv: PORTA_PG_CONNECTION
query: |
SELECT fqdn AS match_host,
'http://tenant-' || id || ':8080' AS backend_url,
true AS preserve_host,
'hardened' AS header_profile
FROM tenants WHERE enabled
tls:
- type: file
path: /etc/porta/tls.yaml
- type: postgres
connectionEnv: PORTA_PG_CONNECTION
query: "SELECT fqdn AS domain, 'http-01' AS challenge FROM tenants WHERE enabled"
Certificates for the new names are batched into the existing multi-SAN groups automatically, 100 SANs per certificate.
Test without burning rate limits#
acme:
email: ops@example.com
use_staging: true
Or point GATEWAY_ACME_DIRECTORY at the staging directory. Certificates are issued by an untrusted root, so browsers complain — which is exactly what you want while rehearsing a cutover. Switch back and reload TLS to get real certificates.
Cutting over from an existing proxy#
- Run Porta on spare ports with the real config and
use_staging: true. Verify routing withcurl --resolve. - Switch to production ACME, keep the old proxy on 80/443 for now, and pre-issue over DNS-01 if you can — that needs no port at all.
- Stop the old proxy, move Porta to 80/443, start. If you must use HTTP-01, this is the point where port 80 has to be free.
- Watch
/healthzand/monitoring/certs; roll back by restarting the old proxy if anything is wrong.
Check whether Porta ends up proxying something its own restart depends on. If it does, break that circle before the cutover — otherwise bringing the gateway back up needs a service that is only reachable through the gateway.
§ 16Troubleshooting#
Symptom first, then the endpoint that answers it.
| Symptom | Look at | Usual cause |
|---|---|---|
| Edited YAML, nothing changed | /monitoring/reload-history | No reload was triggered, or the reload failed and the last-known-good is still being served. |
| A route disappeared | /monitoring/routes, then /monitoring/config-sources | Unknown header_profile (the route is dropped and alerted), or a later source overrode the same (host, path). |
Certificate stuck on pending | /monitoring/certs → waitReason, lastError | not_attempted: no pass has reached it — check lastReconcile.outcome on /monitoring/status. backoff: retry POST /monitoring/reconcile with force. |
Cert looks active but the site does not load | /monitoring/certs → domains[].servedFromCache | false means it is on disk but not in the serving cache. Trigger a reconcile, or /admin/reload-certs. |
| Browser shows a closed connection, not a cert warning | /monitoring/certs → recentSniMisses | No certificate matched the SNI name, so the handshake was rejected outright. The counter names the host and how often it was asked for. |
| Renewal keeps failing | /monitoring/certs → consecutiveFailures, nextRenewalAttempt | HTTP-01: port 80 not reachable, or the name does not point here (pointsAtGateway: "no"). DNS-01: bad or missing API key. |
/healthz says degraded | /monitoring/config-sources | A source is stale. error and failingSince name it and date it. |
/healthz says unhealthy | checks in the same response | no_active_cert: nothing issued yet. unavailable: a source has never loaded. stalled: the renewal loop has not ticked in 25 h. |
| Monitoring port refuses connections | environment | GATEWAY_MONITORING_TOKEN is empty, so the listener was never bound. |
| Every admin call returns 401 | environment | GATEWAY_ADMIN_API_TOKEN is unset — the API is fail-closed by design. |
| TLS handshake fails to the monitoring port | the hostname you used | The monitoring port serves the same SNI certificates as the public listener; connect by a name that has one, or skip verification deliberately. |
| 502s from one backend | /monitoring/routes/detail?id=… → recentErrors | forwarderError and the exception type name the failure — timeout, refused connection, protocol mismatch. |
| Static route 404s everything | the root path | The directory is not visible to the gateway process, or the path is spelled from the wrong side of an isolation boundary. |
| Cipher list appears ignored | the OS | cipher_suites applies on Linux/macOS only; on Windows, Schannel policy governs. |
A 30-second triage#
curl -sk $PORTA/healthz | jq .
curl -s -H "Authorization: Bearer $TOKEN" $PORTA/monitoring/status | jq .
curl -s -H "Authorization: Bearer $TOKEN" $PORTA/monitoring/config-sources | jq '.[] | select(.state != "ok")'
curl -s -H "Authorization: Bearer $TOKEN" $PORTA/monitoring/certs \
| jq '.certs[] | select(.status != "active") | {certId, status, lastError}'
curl -s -H "Authorization: Bearer $TOKEN" "$PORTA/monitoring/audit?limit=20&severity=error" | jq .
Those five calls cover essentially every operational question the gateway can answer about itself.