kubectl times out on a GKE cluster: find the setting that blocks it

dial tcp …:443: i/o timeout means your address is not in the authorized networks of the public endpoint, or your Mac has no route to the private one. Unable to connect to the server: EOF through a bastion or a VPN means that range is missing from the list. net/http: TLS handshake timeout from a bastion in another region means global access is off. A gcloud error about allowExternalTraffic, or an HTML 403 page, means the DNS-based endpoint is closed to outside traffic. The messages below are what kubectl printed on GKE 1.36 when we turned each setting off on a test cluster.

By Ostgate · Updated

Find your error

kubectl or gcloud printsWhere kubectl connectsSetting that blocks it
dial tcp 198.51.100.20:443: i/o timeout The public endpoint Your public IP is not in authorized networks
dial tcp 10.80.0.3:443: i/o timeout The private endpoint, from outside the VPC None: there is no route. Use a path into the VPC
Unable to connect to the server: EOF The private endpoint through a bastion or a VPN The bastion's subnet or the VPN range is not in authorized networks
net/http: TLS handshake timeout The private endpoint through a bastion in another region Global access on the control plane is off
…allowExternalTraffic is disabled for cluster … get-credentials --dns-endpoint The DNS-based endpoint blocks external traffic
Error from server (Forbidden): <!DOCTYPE html> and Error 403 (Forbidden)!!1 The DNS-based endpoint, with an older kubeconfig The same
The connection to the server localhost:8080 was refused Nowhere: get-credentials failed and wrote no entry Read the gcloud error above it
context deadline exceeded (Client.Timeout exceeded while awaiting headers) Any, with --request-timeout set The i/o timeout, cut short

A timeout means nothing answered. EOF and the 403 page are refusals: each answer came back in 31 to 153 ms in our runs.

Read the authorized list before you change it

Three of the fixes below edit the cluster's authorized networks, and --master-authorized-networks replaces the whole list. Read it first, so the update can repeat every range:

gcloud container clusters describe acme-gke \
    --location=europe-central2-a --project=acme-prod \
    --format="value(controlPlaneEndpointsConfig.ipEndpointsConfig.authorizedNetworksConfig.cidrBlocks[].cidrBlock)"
10.80.1.0/24;10.99.0.0/24

If the cluster enforces the list on its private endpoint (privateEndpointEnforcementEnabled: true in the full describe output), keep --enable-authorized-networks-on-private-endpoint in every update, as the commands below do.

Unable to connect to the server: dial tcp …:443: i/o timeout

On a cluster with its public endpoint on, and a Mac whose address was not in the list, kubectl get ns printed five memcache.go lines 30 seconds apart, then:

Unable to connect to the server: dial tcp 198.51.100.20:443: i/o timeout

It took 150 seconds, and we ran it 83 seconds after the last change to the cluster had returned, so it was not a change still spreading. With --request-timeout=20s the same case ends in context deadline exceeded (Client.Timeout exceeded while awaiting headers).

Add your Mac's public IPv4 as a /32. To read it:

dig -4 +short TXT o-o.myaddr.l.google.com @ns1.google.com

Then restate the list with it, here 203.0.113.7:

gcloud container clusters update acme-gke \
    --location=europe-central2-a --project=acme-prod \
    --enable-master-authorized-networks \
    --master-authorized-networks=10.80.1.0/24,10.99.0.0/24,203.0.113.7/32 \
    --enable-authorized-networks-on-private-endpoint

kubectl listed namespaces one second after this returned.

The same i/o timeout, also after 150 seconds, appears when the kubeconfig's server is the private address, for example https://10.80.0.3 from get-credentials --internal-ip, and your Mac is outside the VPC. No cluster setting fixes that one: the Mac needs a path into the network. kubectl to a private GKE cluster from a Mac covers the DNS-based endpoint, the Connect gateway, a VPN and an IAP bastion.

Unable to connect to the server: EOF

We kept an SSH forward from 127.0.0.1:18443 through a bastion VM to the private endpoint, and removed the bastion's subnet from authorized networks. Sixteen seconds after the update returned, kubectl through the forward stopped working:

Unable to connect to the server: EOF

It took 10.66 seconds. With -v=6, kubectl shows ten tries, each one answered in 31 to 106 ms and followed by "Got a Retry-After response" delay="1s". With --request-timeout=10s the message is error: client rate limiter Wait returned an error: context deadline exceeded - error from a previous attempt: EOF. At the TLS layer, openssl s_client -connect 127.0.0.1:18443 -servername kubernetes.default connected and then printed unexpected eof while reading: the TCP connection opens and the control plane drops the handshake. A VPN client range missing from the list fails the same way.

Read the bastion's subnet, then the subnet's range:

gcloud compute instances describe bastion-1 \
    --zone=europe-central2-a --project=acme-prod \
    --format="value(networkInterfaces[0].subnetwork.basename(),networkInterfaces[0].networkIP)"
gcloud compute networks subnets describe acme-bastion-subnet \
    --region=europe-central2 --project=acme-prod \
    --format="value(ipCidrRange)"
acme-bastion-subnet	10.80.1.2
10.80.1.0/24

Then put it back in the list with the ranges that were already there:

gcloud container clusters update acme-gke \
    --location=europe-central2-a --project=acme-prod \
    --enable-master-authorized-networks \
    --master-authorized-networks=10.80.1.0/24,10.99.0.0/24 \
    --enable-authorized-networks-on-private-endpoint

kubectl through the forward worked six seconds after it returned. For a VPN, the range is the client range the VPC routes back to the VPN; on our test VPN kubectl worked 47 seconds after that range was added. Setting up the bastion forward itself, with the tls-server-name it needs, is in IAP bastion: ssh -L and tls-server-name.

net/http: TLS handshake timeout

We tried a bastion in europe-west1-b against a control plane in europe-central2-a, with the far bastion's subnet 10.81.0.0/24 in authorized networks and global access off. kubectl failed with:

Unable to connect to the server: net/http: TLS handshake timeout

Turning on global access fixed it, and kubectl listed the pods:

gcloud container clusters update acme-gke \
    --location=europe-central2-a --project=acme-prod \
    --enable-master-global-access

With global access off, the far bastion got the same message whether its subnet was in the list or not, so after enabling global access, check the list too. A bastion in the same region whose subnet is missing gets EOF instead.

allowExternalTraffic is disabled, and the 403 page

On a cluster whose DNS-based endpoint blocks external traffic, gcloud refuses to write the kubeconfig entry:

ERROR: (gcloud.container.clusters.get-credentials) controlPlaneEndpointsConfig.dnsEndpointConfig.allowExternalTraffic is disabled for cluster acme-gke.

If you run kubectl anyway, with no entry written, it falls back to The connection to the server localhost:8080 was refused - did you specify the right host or port?, which says nothing about GKE.

A kubeconfig written while the endpoint was open gets an HTML error page instead, printed five times as memcache.go errors and once more at the end:

Error from server (Forbidden): <!DOCTYPE html>
<html lang=en>
  <meta charset=utf-8>
  <meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
  <title>Error 403 (Forbidden)!!1</title>
  ...
  <p><b>403.</b> <ins>That’s an error.</ins>
  <p>  <ins>That’s all we know.</ins>

It came back in 56 to 153 ms each time. An administrator allows external traffic with:

gcloud container clusters update acme-gke \
    --location=europe-central2-a --project=acme-prod \
    --enable-dns-access

The old kubeconfig worked two seconds after the update returned, and get-credentials --dns-endpoint wrote a new entry. If the cluster's owners want the DNS endpoint closed, a bastion does not help: in our test the endpoint answered with the same 403 from inside the VPC and through private.googleapis.com. Use another path instead.

What we tested on

A zonal Standard cluster on v1.36.4-gke.2046003 in europe-central2-a, with kubectl v1.35.0 and Google Cloud SDK 590.0.0 on a Mac with macOS 27. The i/o timeout, EOF and 403 cases and their fixes were run on 3 November 2026 with kubectl's default output. The TLS handshake timeout and the VPN case are from 2 November 2026 on the same cluster.

Questions

Why does kubectl time out with dial tcp ...:443: i/o timeout on GKE?

Nothing answers at that address from where you are. On the public endpoint, your public IP is not in the cluster's authorized networks. On the private address, your machine has no route into the VPC. kubectl retries for about 150 seconds before it prints the error.

Does --master-authorized-networks add a range to the list?

No. It replaces the whole list. Read the current ranges with gcloud container clusters describe and repeat all of them, plus the new one, in the update.

Why does kubectl through a bastion fail with EOF?

The cluster enforces authorized networks on its private endpoint and the bastion's subnet range is not in the list. The TCP connection opens and the control plane drops the TLS handshake. Add the subnet's ipCidrRange to the list.

What does Error 403 (Forbidden)!!1 from kubectl mean on GKE?

kubectl reached the cluster's DNS-based endpoint while it blocks external traffic, and it answered with an HTML 403 page. It answers at once and does not time out. gcloud container clusters update with --enable-dns-access allows external traffic.

kubectl through an IAP bastion. Ostgate's forward profiles carry the control plane's port through a bastion VM without a terminal; the setup is in kubectl to a private GKE cluster from a Mac.