← back to /blog← powrót do /blog
Pinning a CI runner's egress IP with one curl
Plenty of things key on your egress IP: database allowlists, third-party API firewalls, a partner VPN, an S3 bucket policy, an SMTP relay that only accepts a known sender. As long as the address is stable, nobody thinks about it. The moment it changes (a new NAT gateway, a recycled self-hosted runner, a cloud provider rotating an address), jobs start failing deep in the pipeline with a connection timeout and no obvious cause.
I keep hitting this with self-hosted CI runners, so I wired a tiny check into the front
of the pipeline. It uses ip.syschecks.com, a
what-is-my-IP service I run: curl it and you get back exactly the address the
outside world sees.
Fail fast on the wrong egress
The cheapest version is a single step at the top of the job. Resolve the public IP, compare it to the addresses you have allowlisted, and stop before anything else runs:
- name: Verify runner egress IP
run: |
IP=$(curl -fsS https://ip.syschecks.com)
echo "runner egress: $IP"
case "$IP" in
203.0.113.*|198.51.100.10) echo "allowlisted, continuing" ;;
*) echo "::error::egress IP $IP is not in the allowlist"; exit 1 ;;
esac
Now a rotated address fails in ten seconds with a clear message, instead of thirty minutes later as a “could not connect to database” that sends three people digging through security groups.
More than an address
The plain-text endpoint returns just the IP. Add /json when you want to
assert on more than the address: country, ASN, or whether traffic is suddenly
leaving through a datacenter or VPN that should not be in the path:
curl -fsS https://ip.syschecks.com/json | jq -r '.ip, .country, .asn, .privacy.type'
A useful guard for regulated workloads: refuse to deploy if egress leaves the expected
country, or if privacy.type comes back as vpn / tor /
hosting when you expected a specific corporate range.
- name: Egress sanity
run: |
JSON=$(curl -fsS https://ip.syschecks.com/json)
CC=$(echo "$JSON" | jq -r .country)
TYPE=$(echo "$JSON" | jq -r .privacy.type)
[ "$CC" = "PL" ] || { echo "::error::egress country $CC, expected PL"; exit 1; }
[ "$TYPE" != "tor" ] || { echo "::error::egress via Tor - refusing to deploy"; exit 1; }
Self-registering a dynamic runner
If the runner IP changes on purpose (an autoscaled fleet, a spot instance), the same call feeds an allowlist update instead of a hard failure. Grab the IP, authorize it on a security group, and revoke it again at teardown so you do not leave the hole open:
IP=$(curl -fsS https://ip.syschecks.com)
aws ec2 authorize-security-group-ingress \
--group-id sg-0123456789abcdef0 \
--protocol tcp --port 5432 --cidr "$IP/32"
# ... run the job ...
aws ec2 revoke-security-group-ingress \
--group-id sg-0123456789abcdef0 \
--protocol tcp --port 5432 --cidr "$IP/32"
Why an external echo, not instance metadata
Cloud metadata gives you the instance address, which is often not the address the internet sees. Behind a NAT gateway, an egress proxy, or a corporate breakout, the metadata IP and the real egress IP differ, and the allowlist only cares about the latter. An external echo reflects what the remote service actually receives, which is the whole point of the check.
The service is plain HTTP with CORS, so it behaves the same from a shell, a container, or a browser, and it does not log the addresses it reflects.
Where it lives
- Tool: ip.syschecks.com: plain text,
/json,/all,/rbl, and API docs at/docs - Part of the syschecks.com monitoring suite: uptime, TLS, DNS and blacklist checks with alerting
- Paweł