kubectl unable to connect to the server ci, or refused, or neither
A kubectl unable to connect to the server ci failure comes from a three-branch function that picks its sentence by what kind of error it was handed, so the wording tells you precisely how far your request got before it died. The branch nobody reads correctly is the one naming localhost:8080, which is not a cluster at all but the built-in default kubectl falls back to when no kubeconfig ever reached the job.

What this error means
A kubectl step prints several E<date> ... memcache.go lines about a server API group list, then one plain sentence, then exits 1. The plain sentence is the one to read and there are three of them. Error from server (<Reason>): ... means the API server answered and refused; the connection is fine and your problem is authorization, admission or the resource. The connection to the server <host> was refused - did you specify the right host or port? means a TCP connection was actively refused. Unable to connect to the server: <error> is everything else that failed at the transport layer, with the underlying Go error appended verbatim, so a timeout, a DNS failure and a certificate rejection all arrive through this one sentence and are distinguished only by what follows the colon.
E0921 10:18:28.030923 33988 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"https://192.0.2.1/api?timeout=5s\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)"
E0921 10:18:33.033029 33988 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"https://192.0.2.1/api?timeout=5s\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)"
E0921 10:18:38.034610 33988 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"https://192.0.2.1/api?timeout=5s\": context deadline exceeded"
E0921 10:18:43.036885 33988 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"https://192.0.2.1/api?timeout=5s\": net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)"
E0921 10:18:48.038673 33988 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"https://192.0.2.1/api?timeout=5s\": context deadline exceeded (Client.Timeout exceeded while awaiting headers)"
Unable to connect to the server: context deadline exceeded (Client.Timeout exceeded while awaiting headers)One function, three sentences
StandardErrorMessage in staging/src/k8s.io/kubectl/pkg/cmd/util/helpers.go is where all three come from. It first asks whether the error implements the API status interface: if it does, the API server sent a structured response, and kubectl prints Error from server (<Reason>) or error: You must be logged in to the server (...) for an unauthorized reason. Reaching either of those proves the request completed.
If not, it type-switches on the error. For a *url.Error whose inner error text contains the substring connection refused, it prints the refused sentence with the host extracted from the URL. For any other *url.Error, it prints Unable to connect to the server: followed by the inner error. Anything that is neither falls through to the generic handler.
That substring test is why the refused sentence is so specific and the other one is so vague. A connection refused is a single, unambiguous kernel answer, so kubectl can ask a pointed question about your host and port. Everything else is whatever Go's HTTP stack said, which is why a TLS problem, a DNS problem and a timeout share a sentence. The diagnosis is always after the colon, never in the sentence.
| Sentence | How far the request got | What to look at |
|---|---|---|
Error from server (Forbidden): ... | All the way. The API server answered. | RBAC, the service account, admission webhooks. |
... was refused on a real cluster host | A TCP handshake, actively refused. | The port, the endpoint, a proxy in front of the API. |
... was refused on localhost:8080 | Nowhere. No kubeconfig was ever loaded. | The step that should have written the kubeconfig. |
Unable to connect: ...i/o timeout | Packets left, nothing came back. | Security groups, private endpoints, egress rules. |
Unable to connect: ...x509: ... | A connection, then a rejected certificate. | The CA bundle in the kubeconfig, or interception. |
Unable to connect: ...no such host | A failed name lookup. | The server URL, split-horizon DNS, the resolver. |
Common causes
No kubeconfig reached the step
The credential step is in another job, so its filesystem is gone; or it writes to $HOME/.kube/config while the failing step runs in a container with a different home; or the cloud login succeeded and the cluster credential fetch did not. The signature is unmistakable once you know it: the host in the message is localhost:8080 and no part of your infrastructure is named anywhere in the log.
The API endpoint is private and the runner is outside it
A cluster with a private control plane accepts connections only from named networks, so a hosted runner gets no answer at all and the error is a timeout rather than a refusal. This is the case that looks intermittent when a subset of your runners sit inside the allowed range and the rest do not, which makes it look like a flake rather than a topology problem.
The certificate authority in the kubeconfig does not match what is served
A rotated cluster CA, a kubeconfig generated against a different cluster, or a TLS-intercepting proxy in the egress path all produce an x509 error appended to the generic sentence. In our experience the interception case is the one that confuses teams longest, because the same kubeconfig works from a laptop on a different network and the workflow has not changed.
The request did arrive, and you are reading the wrong sentence
If the line begins Error from server, nothing about connectivity is wrong. The API server received the request, evaluated it and refused, and the reason in brackets is the actual diagnosis. Grouping this with the connection failures, which happens whenever an alert matches on the word server, sends people to inspect networking for a problem that is entirely in RBAC.
How to fix it
Assert the context before you use it
- Run
kubectl config current-contextas its own step immediately after the credential step. It fails loudly when no kubeconfig was written, instead of letting the next command fall back to the default host. - Print the server URL too. Seeing your real API endpoint once per run costs nothing and makes the
localhost:8080case impossible to misread. - Keep the credential step and the kubectl steps in the same job. A kubeconfig written in one job does not exist in the next.
- name: Verify cluster access
run: |
set -euo pipefail
kubectl config current-context
kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}'; echo
kubectl version --output=json --request-timeout=20s >/dev/nullBound the wait so a private endpoint fails fast
The default discovery timeout is long enough that an unreachable endpoint burns minutes before saying so, and the retries multiply it. A short --request-timeout on the first command turns a slow mystery into a quick, specific timeout message, which is the one you want in the log.
- run: kubectl --request-timeout=20s get nodes -o nameSeparate the certificate case from the reachability case
When the sentence carries an x509 error, the connection succeeded, so stop looking at firewalls. Compare the certificate the endpoint actually serves against the CA data in your kubeconfig. If the issuer you see is a corporate proxy rather than your cluster CA, the egress path is intercepting TLS and no kubeconfig change will fix it.
server=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
host=${server#https://}
echo | openssl s_client -connect "${host%%/*}:443" -servername "${host%%:*}" 2>/dev/null | openssl x509 -noout -issuer -subjectMatch your alerts on the three sentences, not on a shared word
Since the three outcomes need three different owners, a log rule that fires on server will page the wrong team most of the time. Matching the distinctive phrases separately is a five minute change that stops a recurring misroute, and the phrases are stable because they are format strings in the client rather than server text.
grep -E "Error from server|was refused - did you specify|Unable to connect to the server:" deploy.loglocalhost:8080 means no kubeconfig, not a broken cluster
This is the highest-value line on the page, because a CI job pointed at localhost:8080 is almost never a cluster problem and is almost always treated as one. The address is a built-in default. getDefaultServer in the client libraries returns the value of KUBERNETES_MASTER if it is set and otherwise the string http://localhost:8080, and ClusterDefaults is built from it. The command line runtime wires that in explicitly: config_flags.go constructs its overrides as &clientcmd.ConfigOverrides{ClusterDefaults: clientcmd.ClusterDefaults}.
So when nothing supplies a cluster, kubectl does not complain that it has no configuration. It quietly uses a default that was designed for a developer running an API server on their own machine, and then reports, correctly and unhelpfully, that nothing is listening there. The block below is that exact case, captured with the kubeconfig pointed at an empty file.
Once you know the address is a default rather than a destination, the fix moves to a different part of the workflow: the step that was supposed to produce credentials either did not run, ran in a different job, wrote to a path this step does not read, or failed in a way the workflow ignored. None of those are visible from the cluster side.
$ kubectl --kubeconfig=/dev/null get pods
E0921 10:18:13.820410 33779 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"http://localhost:8080/api?timeout=32s\": dial tcp [::1]:8080: connect: connection refused"
The connection to the server localhost:8080 was refused - did you specify the right host or port?Why this page has no runner reproduction
Every branch on this page is reachable from a laptop with no cluster at all, which is the point: the failures are properties of the client, not of any particular Kubernetes installation. Pointing the client at the TEST-NET-1 address reserved by RFC 5737 produces the timeout branch, and handing it an empty kubeconfig produces the default-host branch, both in seconds and both without touching anybody's cluster.
A runner reproduction would have added the one thing that cannot be generalised anyway, which is what your cloud provider's private endpoint does to packets from a particular network. That answer differs per provider and per cluster, so a capture from ours would be an anecdote presented as a rule. The two blocks here were produced on 2026-09-21 on kubectl v1.36.1, the addresses in them are documentation addresses and a local default, and neither came from a runner. content/heal-evidence.mjs has no record for this slug, so no repair claim is made anywhere on the page.
How to prevent it
- Assert
kubectl config current-contextright after the credential step, in the same job. - Print the API server URL once per run, so the default host can never be mistaken for your cluster.
- Set a short --request-timeout on the first kubectl call so an unreachable endpoint fails in seconds.
- Alert on the three sentences separately. Only one of them is about the network.
Frequently asked questions
Why does kubectl say localhost:8080 when my cluster is somewhere else?
getDefaultServer in the client libraries returns KUBERNETES_MASTER if it is set and otherwise http://localhost:8080, and the command line runtime passes that in as the cluster default. When nothing supplies a server, kubectl uses it silently, so the message is telling you that nothing is listening on your own machine.What is the difference between "Unable to connect" and "connection refused" in kubectl?
connection refused, which means a TCP handshake reached the host and was rejected. Everything else at the transport layer gets Unable to connect to the server: with the raw Go error appended, so timeouts, DNS failures and certificate rejections all share that wording.Does "Error from server" mean kubectl could not reach the cluster?
What are the memcache.go lines above the error?
Related guides
References
- kubernetes/kubernetes: StandardErrorMessage, where the three sentences are chosen
- kubernetes/kubernetes: getDefaultServer and ClusterDefaults in clientcmd
- kubernetes/kubernetes: config_flags.go, which passes ClusterDefaults into the overrides
- Kubernetes docs: organizing cluster access using kubeconfig files
- Kubernetes documentation
- GitHub Actions documentation