Cross-account Prometheus replication with end-to-end mTLS via Envoy
There are easier ways to ship metrics across AWS accounts than the one I’m about to describe. You could push to Amazon Managed Prometheus. You could peer the VPCs. You could use PrivateLink. We didn’t — we wanted both ends to be self-hosted Prometheus, separated by an account boundary, and we wanted the link to be the kind of thing a compliance reviewer can audit end-to-end. So the wire ended up going across the public internet with mutual TLS as the only thing standing between any random host on the internet and our metrics ingest endpoint.
This post walks through the design: how the two Prometheus servers talk to each other, what Envoy does on each side, how the certificates fit together, and the networking and security considerations that ended up shaping the final shape of the system.
The setup at a glance
┌────────────── Account A (sender) ──────────────┐ ┌──────────── Account B (receiver) ────────────┐
│ │ │ │
│ ┌──────────┐ ┌─────────┐ ┌────────────┐ │ TCP │ ┌─────┐ ┌─────────┐ ┌─────────────┐ │
│ │Prometheus│──>│ Envoy │──>│ NAT (EIP) │───┼──443───┼─>│ NLB │──>│ Envoy │──>│ Prometheus │ │
│ │ (scraper)│ │ (mTLS │ │ stable │ │ public │ │ │ │ (mTLS │ │ (receiver, │ │
│ │ │ │ client) │ │ egress IP │ │ internet│ │ │ │ server) │ │ remote_w) │ │
│ └──────────┘ └─────────┘ └────────────┘ │ │ └─────┘ └─────────┘ └─────────────┘ │
│ │ │ │
└─────────────────────────────────────────────────┘ └──────────────────────────────────────────────┘
- Account A runs Prometheus that scrapes everything it cares about and
uses
remote_writeto ship samples elsewhere. Theelsewhereis a local Envoy on the same host (or sidecar), which terminates outbound TLS, adds its client certificate, and dials the public endpoint in Account B. - The traffic leaves Account A through a NAT gateway with a pinned Elastic IP, so the egress IP is stable.
- It lands in Account B on a public Network Load Balancer that fronts another Envoy. Account B’s security group allows port 443 only from Account A’s NAT EIP. So even a perfectly forged TLS handshake from a random host on the internet hits a closed socket.
- Envoy in Account B terminates TLS, validates the client certificate against a trusted intermediate CA, and forwards the request to a receiver-mode Prometheus running locally.
Three independent layers of access control, in order of how early they reject a bad request:
- Network ACL (Account B’s SG) — only Account A’s egress IP can even open a connection.
- mTLS server validation — Envoy rejects clients that don’t present a certificate signed by the intermediate CA we trust.
- mTLS client validation — Account A’s Envoy refuses to send to a server whose cert isn’t signed by the same intermediate.
You need all three to fail for an exfiltration or injection to land.
Prometheus side
The sender’s prometheus.yml is boring on purpose — all of the security
logic is delegated to the local Envoy.
# Account A: prometheus.yml (excerpt)
remote_write:
- url: http://127.0.0.1:9091/api/v1/write
queue_config:
capacity: 10000
max_samples_per_send: 2000
max_shards: 50
min_backoff: 100ms
max_backoff: 30s
metadata_config:
send: true
send_interval: 1m
A few things worth noting here:
- The URL is
http://(plain) and127.0.0.1(loopback). The crypto happens one hop later, in the Envoy sidecar. queue_configis tuned higher than the defaults because cross-account links have higher latency and more variance than intra-cluster remote_write. Bumpingmax_shardslets Prometheus parallelize when the link is healthy; the WAL acts as the backpressure buffer when it isn’t.metadata_config.send: trueships sample metadata so the receiver side can render correct help text in/api/v1/metadata. It’s free bytes and worth it.
The receiver is also boring:
# Account B: prometheus startup flags
prometheus \
--config.file=/etc/prometheus/prometheus.yml \
--storage.tsdb.path=/var/lib/prometheus \
--web.listen-address=127.0.0.1:9090 \
--web.enable-remote-write-receiver \
--enable-feature=memory-snapshot-on-shutdown
--web.enable-remote-write-receiver is the magic flag — it turns
Prometheus into a receiver for the remote-write protocol on
/api/v1/write. Without it, the endpoint 404s. --web.listen-address
binds to loopback only; nothing on the network can reach Prometheus
directly. Everything has to come through Envoy.
Envoy on the receiver (Account B)
This is the more interesting half of the configuration. Envoy is the public face of the metrics ingest endpoint — it terminates TLS, validates the client certificate, and acts as a reverse proxy to Prometheus.
# Account B: envoy.yaml (excerpt)
static_resources:
listeners:
- name: metrics_ingest
address:
socket_address: { address: 0.0.0.0, port_value: 8443 }
filter_chains:
- transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
require_client_certificate: true
common_tls_context:
tls_certificates:
- certificate_chain: { filename: /etc/envoy/certs/server.crt }
private_key: { filename: /etc/envoy/certs/server.key }
validation_context:
# Trust the intermediate CA only — not any random
# root the OS happens to trust.
trusted_ca: { filename: /etc/envoy/certs/intermediate-ca.crt }
match_typed_subject_alt_names:
- san_type: DNS
matcher:
exact: "metrics-sender.account-a.internal"
filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
virtual_hosts:
- name: prom_ingest
domains: ["*"]
routes:
- match: { prefix: "/api/v1/write" }
route: { cluster: prometheus_local }
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
access_log:
- name: envoy.access_loggers.stdout
typed_config:
"@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
log_format:
text_format_source:
inline_string: |
%START_TIME% %DOWNSTREAM_PEER_SUBJECT% %REQ(:METHOD)% %REQ(:PATH%)% %RESPONSE_CODE% %BYTES_RECEIVED%
clusters:
- name: prometheus_local
connect_timeout: 1s
type: STATIC
load_assignment:
cluster_name: prometheus_local
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 127.0.0.1, port_value: 9090 }
The bits that actually matter, called out:
require_client_certificate: true— the handshake fails if the client doesn’t present a cert. No “optional” mTLS, no falling back to one-way TLS for clients that “haven’t migrated yet.”trusted_capoints at the intermediate CA, not the system root store. This is critical. If you point it at the OS truststore you’ve effectively said “anything Let’s Encrypt or DigiCert signs is fine,” which is the opposite of what mTLS is supposed to give you. The trust anchor must be the small internal CA you control.match_typed_subject_alt_namespins the expected SAN on the client cert. Without this, any cert signed by your intermediate would be accepted, including ones for other services that happen to be in the same trust domain. Pinning the SAN means a stolen-but-different cert from inside the trust domain still gets rejected.access_logincludesDOWNSTREAM_PEER_SUBJECT— the client cert’s subject DN is logged on every request. This is the audit trail that says “this metric came from a system holding cert X.” When the compliance reviewer asks “who can write to this Prometheus?” the answer is in the logs.- The route is restricted to
/api/v1/write. The receiver Prometheus has plenty of other endpoints (/api/v1/query,/metrics, the UI), and none of them are reachable through Envoy. The sender can write, and only write.
Envoy on the sender (Account A)
The sender’s Envoy is the mirror image: it terminates the upstream TLS, presents the client cert, and validates the server side.
# Account A: envoy.yaml (excerpt)
static_resources:
listeners:
- name: prom_remote_write_egress
address:
socket_address: { address: 127.0.0.1, port_value: 9091 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: egress_http
route_config:
virtual_hosts:
- name: remote_write
domains: ["*"]
routes:
- match: { prefix: "/api/v1/write" }
route: { cluster: remote_prometheus }
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: remote_prometheus
connect_timeout: 5s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: remote_prometheus
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: metrics-ingest.example.com
port_value: 443
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
sni: metrics-ingest.example.com
common_tls_context:
tls_certificates:
- certificate_chain: { filename: /etc/envoy/certs/client.crt }
private_key: { filename: /etc/envoy/certs/client.key }
validation_context:
trusted_ca: { filename: /etc/envoy/certs/intermediate-ca.crt }
match_typed_subject_alt_names:
- san_type: DNS
matcher:
exact: "metrics-ingest.account-b.internal"
Same trust anchor, same pinning discipline — just from the other side.
A subtle detail: the upstream TlsContext’s validation_context.trusted_ca
must contain the intermediate that signed the server cert. The server
cert’s chain (sent during handshake) chains up to that intermediate. If
you instead put only the root CA in the trust store, it works — but you
lose the ability to revoke at the intermediate level, which is
usually the whole point of running a two-tier PKI.
The networking layer
The transport choices were the trickiest part to justify on paper.
Why public internet, not VPC peering or PrivateLink?
VPC peering would have worked, but it requires both sides’ networking teams to coordinate on CIDR blocks, route tables, and security group references — and that coordination needs to be redone any time either side re-IPs. PrivateLink is cleaner but is a single-vendor (AWS) story: the moment one side moves out of AWS or runs from a different cloud for DR, you’re rebuilding the link. The public internet path is portable; the mTLS + IP-allow-list combination provides the security envelope without binding to a specific AWS networking primitive.
The trade-off is throughput and latency: this path crosses internet hops, so a regional outage in either direction is a hard cut. Prometheus’s local WAL absorbs that — samples accumulate and replay when the link comes back, up to the WAL’s retention window (typically 2 hours).
Why a NAT with a pinned EIP?
The Account B security group needs a stable source IP to allow-list. The default behavior of an AWS NAT Gateway with no explicit EIP can survive fine, but if it ever gets re-created (Terraform destroy/apply, AZ failover, region migration), the IP changes and Account B starts silently rejecting traffic. Pinning the NAT to a pre-allocated EIP makes the egress IP part of state you explicitly version.
In Terraform that’s a four-line pattern:
resource "aws_eip" "metrics_egress" {
domain = "vpc"
tags = { Name = "metrics-egress" }
}
resource "aws_nat_gateway" "metrics_egress" {
allocation_id = aws_eip.metrics_egress.id
subnet_id = aws_subnet.public_a.id
}
The receiver side allow-lists aws_eip.metrics_egress.public_ip and is
done. EIPs are sticky and survive NAT recreation.
Why an NLB, not an ALB?
An ALB terminates TLS itself and would either bypass the Envoy behind it or require a second TLS hop. The point of Envoy is to be the single TLS termination point; an NLB lets the TLS connection pass through untouched at L4. The NLB exists only for DNS stability and public IP attachment.
The certificate chain
A clean PKI layout helped a lot more than I expected when I was setting this up. The structure was:
Internal Root CA (offline, in HSM)
│
│ signs
▼
Internal Intermediate CA (online, short-lived)
│ │
│ signs │ signs
▼ ▼
Server cert Client cert
(metrics-ingest.…) (metrics-sender.…)
Account B Envoy Account A Envoy
Both Envoys trust the intermediate, not the root, in their
trusted_ca. Why:
- Revocation is cheap at the intermediate level. If a leaf cert is compromised, you don’t need to wait for the leaf to expire — you re-issue the intermediate and the old leaf is automatically untrusted on the next handshake. Both sides pull the new intermediate at the next certificate-rotation cycle.
- The root stays offline. It only ever signs intermediates. It isn’t loaded on any production box, which means a compromise of any running Envoy host cannot extract material that re-signs new intermediates.
- The intermediate is short-lived (think days, not years). Cert rotation is a routine cron, not a fire drill.
The most underrated thing about this design is what it lets you do
operationally: when something is broken, the question “is the cert
expired, on either side, or is something else broken?” can be answered
by running a five-line openssl s_client against the public endpoint
and checking the dates. The trust chain is short enough that a human
can mentally model the failure modes.
What we learned to watch
A few things broke in instructive ways:
prometheus_remote_storage_failed_samples_total— this is the alert you actually want. It catches everything from “cert expired” to “receiver out of disk” to “we tripped the rate limit on the receiver.” Without it, the failure mode is silent: Prometheus keeps trying, keeps failing, WAL grows, eventually the sender’s disk fills up and scrape failures start happening too.prometheus_wal_segment_current— if this is climbing without bound on the sender, the link is congested or down. The WAL is doing its job, but you have a finite amount of time before it stops doing its job.- Envoy’s
ssl.fail_verify_certcounter on the receiver — non-zero values mean someone (probably your own infrastructure mid-rotation, occasionally something actually wrong) is trying to connect with a cert that doesn’t pass validation. Alert on it. - The NAT EIP — sounds dumb, but: alert if the EIP allocation disappears from state. We had a single incident where a Terraform refactor moved the EIP into a different module without re-using the same allocation ID, the new EIP wasn’t on the allow list, and Account B started rejecting writes within seconds.
The summary
The shape of the system, distilled:
- Prometheus on both sides, both self-hosted, separated by an AWS account boundary.
- Envoy sidecars on each side handle all the cryptography and certificate lifecycle. Prometheus stays cleartext, local, simple.
- mTLS with a private intermediate CA, with SAN pinning on both sides, is the inner fence.
- A pinned-EIP NAT and a single-IP allow-list on the receiver SG is the outer fence.
- An NLB sits between the internet and the receiver Envoy so the public DNS name and IP are stable independent of how Envoy is deployed.
- The WAL on the sender absorbs link outages; alerts on
failed_samples_totalandwal_segment_currentcatch real problems.
None of this is novel — every component is documented somewhere — but the combination is what gave us a metrics replication link we’d be happy to put in front of an auditor and confident we’d hear about it before it failed silently.