Proof of concept · Platform evaluation
Can Cloudflare Containers send a ping?
We deployed a throwaway container to find out whether outbound ICMP works. This page covers the result, the evidence, and what to do instead if you were planning to rely on it.
Outbound ICMP from a Cloudflare Container doesn't reach the internet. Pings to public addresses get 100% packet loss, and the container's own gateway answers them with Destination Net Unreachable.
The container can still open raw sockets and send ICMP packets. They're refused by the Cloudflare network the container sits on, not blocked inside the container, so this isn't something you can fix with permissions or image changes.
Why we tested this
Ping is the simplest way to check that a host is reachable and to measure round-trip latency. Plenty of tools and health checks assume it works. Cloudflare's Containers docs cover HTTP and HTTPS egress, outbound interception and the enableInternet switch, but they don't mention ICMP at all. Before building anything that depends on it, we wanted a definite answer from a real deployment.
What we found
Every run gave the same result. The control tests show the container's networking is otherwise healthy, so the ICMP failures can't be put down to a broken network.
| What we tried | Result | Detail |
|---|---|---|
ping to 1.1.1.1, 8.8.8.8, one.one.one.one | Blocked | 100% loss. The gateway 10.0.0.2 replies with ICMP type 3, code 0 (Destination Net Unreachable). |
ping -6 to 2606:4700:4700::1111 | Blocked | 100% loss. Dropped silently, with no error coming back. |
Raw ICMP sockets (what ping uses under the hood) | Blocked | Opening the socket and sending both work. Only the gateway's "unreachable" replies come back. |
| Unprivileged ICMP sockets | Blocked | Off by default. You can turn them on as root, but the traffic hits the same wall. |
| Ping the container's own gateway (10.0.0.2) | Works | 3 of 3 echo replies, so ICMP works inside the container's own network segment. |
| TCP 443 to 1.1.1.1 and 8.8.8.8 | Works | Connects in roughly 10–20 ms. |
| UDP DNS to 1.1.1.1:53 and 8.8.8.8:53 | Works | Valid answers in roughly 10–35 ms. |
| HTTP and HTTPS out (cloudflare.com, deb.debian.org) | Works | Normal responses, and apt package installs work. |
How to read the "unreachable" replies
A firewall that silently drops packets leaves you guessing. This one answers straight away with Destination Net Unreachable, which is the network saying "I won't route this". That reply comes from 10.0.0.2, the container's default gateway, run by Cloudflare. It's a deliberate policy, not a timeout.
You'll notice that some pings in the output show no reply at all, not even an error. That's normal: Linux rate-limits how often it sends ICMP error messages, so after the first few the gateway stays quiet. Either way, no echo reply ever arrived.
What this means for you
- Don't design around ICMP from a container. That rules out ping-based health checks, uptime probes, path-MTU tricks that need ICMP replies, and running
traceroutein its default ICMP mode. - Use TCP or HTTP probes for reachability and latency. Time a TCP connect to the port you actually care about, or call an HTTP health endpoint. Both worked reliably here, and in most cases they're a better signal than ping anyway.
- UDP works, at least for DNS on port 53, so UDP-based protocols are an option.
- This could change. It's undocumented behaviour, not a published limit. If ICMP matters to you, re-run the test before relying on either answer, and consider asking Cloudflare directly.
The evidence
This is the real output of ping (iputils 20240905), installed from Debian inside the running container:
--- ping 1.1.1.1
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
From 10.0.0.2 icmp_seq=1 Destination Net Unreachable
From 10.0.0.2 icmp_seq=2 Destination Net Unreachable
From 10.0.0.2 icmp_seq=3 Destination Net Unreachable
From 10.0.0.2 icmp_seq=4 Destination Net Unreachable
--- 1.1.1.1 ping statistics ---
4 packets transmitted, 0 received, +4 errors, 100% packet loss, time 3057ms
--- ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
--- 8.8.8.8 ping statistics ---
4 packets transmitted, 0 received, 100% packet loss, time 3068ms
--- ping one.one.one.one
PING one.one.one.one (1.1.1.1) 56(84) bytes of data.
From 10.0.0.2 icmp_seq=1 Destination Net Unreachable
--- one.one.one.one ping statistics ---
4 packets transmitted, 0 received, +1 errors, 100% packet loss, time 3048ms
--- ping -6 2606:4700:4700::1111
PING 2606:4700:4700::1111 (2606:4700:4700::1111) 56 data bytes
--- 2606:4700:4700::1111 ping statistics ---
4 packets transmitted, 0 received, 100% packet loss, time 3051msA small probe that builds ICMP echo packets by hand on a raw socket gives the same picture. It gets replies from the gateway itself and "unreachable" for everything past it:
SOCK_RAW -> 10.0.0.2 seq=1: ECHO REPLY from 10.0.0.2 in 0.0 ms
SOCK_RAW -> 10.0.0.2 seq=2: ECHO REPLY from 10.0.0.2 in 0.0 ms
SOCK_RAW -> 10.0.0.2 seq=3: ECHO REPLY from 10.0.0.2 in 0.0 ms
SOCK_RAW -> 10.0.0.2 SUMMARY: sent=3 replies=3
SOCK_RAW -> 1.1.1.1 seq=1: got ICMP type=3 code=0 from 10.0.0.2
SOCK_RAW -> 1.1.1.1 seq=2: got ICMP type=3 code=0 from 10.0.0.2
SOCK_RAW -> 1.1.1.1 seq=3: got ICMP type=3 code=0 from 10.0.0.2
SOCK_RAW -> 1.1.1.1 SUMMARY: sent=3 replies=0
ICMPv6 -> 2606:4700:4700::1111: got type=136 code=0 from fd00::22
ICMPv6 -> 2606:4700:4700::1111: no echo reply within 4s
ICMPv6 -> 2001:4860:4860::8888: no echo reply within 4sHow we tested it
The setup was deliberately minimal: one Worker, one Durable Object and one container, deployed for the test and deleted straight afterwards.
- A Worker with a Durable Object. With Cloudflare Containers, every container belongs to a Durable Object, which starts it, stops it and talks to it. The Worker accepts an HTTP request and hands it to the Durable Object.
- Cloudflare's managed Debian image. We used
cloudflare/debian-trixie(Node 24 on Debian Trixie), started withenableInternet: true, so there was no image to build or push. - Commands run with
exec(). The Durable Object callsctx.container.exec()to run a probe script in the container and sends the output back as the HTTP response. There's no web server inside the container. - The probe script checks the container's privileges and routes, runs the TCP, UDP and HTTPS control tests, tries hand-built ICMP over raw and unprivileged sockets (IPv4 and IPv6), then installs and runs the real
ping.
We ran the probe three times: on a cold lite instance, again after redeploying, and on a fresh standard-1 instance. All three gave identical results.
The limits of this test
- We tested from one Cloudflare location (IAD) on one day. It's very likely a platform-wide policy, but we didn't prove that.
- We used the
durable_objectscheduling policy. We didn't separately test thedefaultscheduling policy, though both run on the same container networking. - We didn't test ICMP into a container, only outbound.
Things we tripped over (useful if you build on Containers)
- Deploying through the API without Wrangler? The Worker upload metadata must include
containers: [{ "class_name": "YourClass" }]. Without it, creating the container application fails with "Durable Object namespace is not Container-enabled". Wrangler adds it for you. - The managed image needs something to keep running. Its default command is the Node REPL, which exits straight away without a terminal. We started it with
entrypoint: ["sleep", "infinity"]and usedexec()for the real work. exec()runs one program directly, not through a shell. For pipes, redirects or scripts, call["bash", "-c", "…"]yourself. Wrap long commands intimeout, becauseexec()has no time limit of its own.liteinstances are slow. With 1/16 of a vCPU,apt-get updatedidn't finish in 90 seconds. Onstandard-1the whole probe, including the install, took about a minute.- Every process runs with root's capabilities under the
durable_objectpolicy, including raw sockets. That's why we could prove the block happens at the network edge rather than in the container.
Reproduce it yourself
Here's the full source we deployed. The Worker needs a Durable Object binding PINGBOX for class PingBox (with a SQLite migration), a POC_TOKEN secret so it isn't an open ping relay, and the containers metadata entry described above. Then call /?token=…&instance=standard-1. The first request takes a minute or two while the container starts.
Worker and Durable Object (worker.js)
import { DurableObject } from "cloudflare:workers";
// The probe script that runs inside the container (generated from probe.sh).
const SCRIPT = `/* paste the contents of probe.sh here */`;
export class PingBox extends DurableObject {
async fetch(request) {
const c = this.ctx.container;
if (!c) return new Response("No container is attached to this Durable Object\n", { status: 500 });
const url = new URL(request.url);
if (url.searchParams.get("reset") === "1") {
if (c.running) await c.destroy();
return new Response("container destroyed\n");
}
let started = false;
if (!c.running) {
c.start({
image: "cloudflare/debian-trixie",
entrypoint: ["sleep", "infinity"],
// ?instance=standard-1 etc. for a faster box; only known sizes are accepted.
instance: ["lite", "standard-1", "standard-2"].includes(url.searchParams.get("instance")) ? url.searchParams.get("instance") : "lite",
enableInternet: url.searchParams.get("internet") !== "0",
});
started = true;
}
await c.setInactivityTimeout(2 * 60 * 1000);
const t0 = Date.now();
const proc = await c.exec(["timeout", "--kill-after=5", "290", "bash", "-c", SCRIPT]);
const out = await proc.output();
const d = new TextDecoder();
const body = `container ${started ? "cold-started" : "already running"}; probe took ${Date.now() - t0}ms; exit=${out.exitCode}\n` +
d.decode(out.stdout) + (out.stderr.byteLength ? `\n==================== STDERR ====================\n${d.decode(out.stderr)}` : "");
return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8" } });
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.searchParams.get("token") !== env.POC_TOKEN) return new Response("forbidden\n", { status: 403 });
return env.PINGBOX.getByName(url.searchParams.get("box") || "poc").fetch(request);
},
};Probe script that runs inside the container (probe.sh)
#!/bin/bash
# ICMP egress probe for Cloudflare Containers. Each section is labelled so the output reads top-to-bottom.
section() { echo; echo "==================== $1 ===================="; }
section "ENVIRONMENT"
echo "date: $(date -u +%FT%TZ)"; echo "id: $(id)"; echo "kernel: $(uname -a)"
grep -E '^Cap(Inh|Prm|Eff|Bnd|Amb)' /proc/self/status
echo "ping_group_range: $(cat /proc/sys/net/ipv4/ping_group_range 2>&1)"
echo "--- /proc/net/dev"; cat /proc/net/dev
echo "--- /proc/net/route"; cat /proc/net/route
echo "--- /proc/net/if_inet6"; cat /proc/net/if_inet6 2>&1
echo "--- /proc/net/ipv6_route (default)"; grep -E '^0{32} 00' /proc/net/ipv6_route 2>&1
echo "--- /etc/resolv.conf"; cat /etc/resolv.conf
section "CONTROL: TCP / HTTPS / UDP (Node)"
timeout 30 node - <<'JS'
const net = require("net"), dgram = require("dgram");
const tcp = (host, port) => new Promise(r => { const t0 = Date.now(); const s = net.connect({ host, port, timeout: 5000 });
s.on("connect", () => { r(`TCP ${host}:${port} OK ${Date.now()-t0}ms`); s.destroy(); });
s.on("timeout", () => { r(`TCP ${host}:${port} TIMEOUT`); s.destroy(); });
s.on("error", e => r(`TCP ${host}:${port} ERROR ${e.code||e.message}`)); });
const dns = (server) => new Promise(r => { const s = dgram.createSocket("udp4"); const t0 = Date.now();
const q = Buffer.from("abcd01000001000000000000076578616d706c6503636f6d0000010001", "hex");
const timer = setTimeout(() => { r(`UDP DNS @${server}:53 TIMEOUT`); s.close(); }, 5000);
s.on("message", m => { clearTimeout(timer); r(`UDP DNS @${server}:53 OK ${Date.now()-t0}ms, ${m.length} byte reply, answers=${m.readUInt16BE(6)}`); s.close(); });
s.on("error", e => { clearTimeout(timer); r(`UDP DNS @${server}:53 ERROR ${e.code||e.message}`); });
s.send(q, 53, server); });
(async () => {
for (const l of await Promise.all([tcp("1.1.1.1", 443), tcp("8.8.8.8", 443), tcp("1.1.1.1", 22), dns("1.1.1.1"), dns("8.8.8.8")])) console.log(l);
try { const t = await (await fetch("https://cloudflare.com/cdn-cgi/trace", { signal: AbortSignal.timeout(8000) })).text();
console.log("HTTPS trace:", t.split("\n").filter(l => /^(ip|colo|loc|warp|http)=/.test(l)).join(" ")); }
catch (e) { console.log("HTTPS trace ERROR", e.cause?.code || e.message); }
})();
JS
section "PERL ICMP SOCKET PROBE (no ping binary needed)"
timeout 40 perl - <<'PL'
use strict; use Socket;
my $hires = eval { require Time::HiRes; Time::HiRes->import("time"); 1 };
sub cksum { my $m = shift; $m .= "\0" if length($m) % 2; my $s = 0; $s += $_ for unpack("n*", $m);
$s = ($s >> 16) + ($s & 0xffff); $s += $s >> 16; return ~$s & 0xffff; }
for my $mode (["SOCK_RAW", SOCK_RAW], ["SOCK_DGRAM(unprivileged ping socket)", SOCK_DGRAM]) {
my ($name, $type) = @$mode;
for my $dst ("10.0.0.2", "1.1.1.1", "8.8.8.8") {
my $s;
unless (socket($s, PF_INET, $type, 1)) { print "$name -> $dst: socket() FAILED: $!\n"; next; }
my $id = $$ & 0xffff; my $ok = 0; my $sent = 0;
for my $seq (1..3) {
my $pkt = pack("CCnnn", 8, 0, 0, $id, $seq) . "cf-icmp-poc";
substr($pkt, 2, 2) = pack("n", cksum($pkt));
my $t0 = time;
unless (send($s, $pkt, 0, sockaddr_in(0, inet_aton($dst)))) { print "$name -> $dst seq=$seq: send() FAILED: $!\n"; next; }
$sent++;
my $got = 0;
while (1) {
my $rin = ''; vec($rin, fileno($s), 1) = 1;
my $left = 3 - (time - $t0); last if $left <= 0;
last unless select(my $rout = $rin, undef, undef, $left);
my $from = recv($s, my $buf, 1500, 0); last unless defined $from;
my $off = ($type == SOCK_RAW) ? (ord(substr($buf, 0, 1)) & 0x0f) * 4 : 0;
my ($t, $c) = unpack("CC", substr($buf, $off, 2));
my $src = inet_ntoa((sockaddr_in($from))[1]);
if ($t == 0) { printf "%s -> %s seq=%d: ECHO REPLY from %s in %.1f ms\n", $name, $dst, $seq, $src, (time - $t0) * 1000; $got = 1; $ok++; last; }
else { print "$name -> $dst seq=$seq: got ICMP type=$t code=$c from $src\n"; }
}
print "$name -> $dst seq=$seq: no reply within 3s\n" unless $got;
}
print "$name -> $dst SUMMARY: sent=$sent replies=$ok\n";
close($s);
}
}
PL
section "TRY ENABLING UNPRIVILEGED PING SOCKETS"
echo "write ping_group_range: $( (echo '0 2147483647' > /proc/sys/net/ipv4/ping_group_range) 2>&1 || true)"
echo "ping_group_range now: $(cat /proc/sys/net/ipv4/ping_group_range)"
section "ICMPv6 RAW SOCKET PROBE"
timeout 20 perl - <<'PL6'
use strict; use Socket qw(AF_INET6 SOCK_RAW inet_pton pack_sockaddr_in6 unpack_sockaddr_in6 inet_ntop);
for my $dst ("2606:4700:4700::1111", "2001:4860:4860::8888") {
socket(my $s, AF_INET6, SOCK_RAW, 58) or do { print "ICMPv6 -> $dst: socket() FAILED: $!\n"; next };
my $pkt = pack("CCnnn", 128, 0, 0, $$ & 0xffff, 1) . "cf-icmp-poc"; # kernel fills in the ICMPv6 checksum
my $t0 = time;
send($s, $pkt, 0, pack_sockaddr_in6(0, inet_pton(AF_INET6, $dst))) or do { print "ICMPv6 -> $dst: send() FAILED: $!\n"; next };
my $got = 0;
while (time - $t0 < 4) {
my $rin = ''; vec($rin, fileno($s), 1) = 1;
last unless select(my $rout = $rin, undef, undef, 4 - (time - $t0));
my $from = recv($s, my $buf, 1500, 0); last unless defined $from;
my ($t, $c) = unpack("CC", $buf); my $src = inet_ntop(AF_INET6, (unpack_sockaddr_in6($from))[1]);
next if $t == 128;
print "ICMPv6 -> $dst: got type=$t code=$c from $src", ($t == 129 ? " <== ECHO REPLY" : ""), "\n";
if ($t == 129) { $got = 1; last }
}
print "ICMPv6 -> $dst: ", ($got ? "REPLY RECEIVED" : "no echo reply within 4s"), "\n";
}
PL6
section "WHY DID APT TIME OUT? (HTTP/HTTPS to deb.debian.org)"
timeout 40 node - <<'JS'
(async () => {
for (const u of ["http://deb.debian.org/debian/dists/trixie/InRelease", "https://deb.debian.org/debian/dists/trixie/InRelease", "http://example.com/"]) {
const t0 = Date.now();
try { const r = await fetch(u, { method: "HEAD", signal: AbortSignal.timeout(12000) }); console.log(`${u} -> HTTP ${r.status} in ${Date.now()-t0}ms (server: ${r.headers.get("server")})`); }
catch (e) { console.log(`${u} -> ERROR ${e.cause?.code || e.name} after ${Date.now()-t0}ms`); }
}
})();
JS
echo "CA bundle present: $(ls /etc/ssl/certs/ca-certificates.crt 2>&1)"
section "INSTALL iputils-ping"
if ! command -v ping >/dev/null; then
export DEBIAN_FRONTEND=noninteractive
(timeout 150 apt-get update -qq && timeout 60 apt-get install -y -qq --no-install-recommends iputils-ping) >/tmp/apt.log 2>&1
echo "apt exit=$?"; tail -5 /tmp/apt.log
fi
command -v ping && ping -V 2>&1 | head -1
section "PING (iputils)"
if command -v ping >/dev/null; then
for t in 1.1.1.1 8.8.8.8 one.one.one.one; do echo "--- ping $t"; timeout 20 ping -c 4 -W 2 "$t" 2>&1; echo "exit=$?"; done
echo "--- ping -6 2606:4700:4700::1111"; timeout 20 ping -6 -c 4 -W 2 2606:4700:4700::1111 2>&1; echo "exit=$?"
else
echo "ping not available (apt install failed)"
fi
section "DONE"The test deployment (Worker, Durable Object and container application) has been deleted. Nothing from this proof of concept is still running.