☸️ Kubernetes on the Cloud: A Managed Cluster and kubectl
What a managed cluster is on Azure, AWS, Google Cloud and Oracle Cloud, how to create one and connect to it, and how to talk to it: kubeconfig, contexts, manifests, Secrets and clean-up, with the parts that need no cluster run for real
💡 How this page was tested. The kubectl parts were run for real. On 6 October 2026 I downloaded kubectl v1.37.1 from the Kubernetes release server, checked its SHA-256 checksum (it matched), and ran it in a throwaway folder with an empty home directory. With no cluster I could still run everything that works offline: the errors you see before you are configured, kubeconfig files and contexts (with dummy servers), manifests written by --dry-run=client, a base64 demonstration with a Secret, and kubectl kustomize. The address I pointed the fake cluster at (203.0.113.10) is in a range reserved for documentation, so nothing answers there, which is also a real example of what an unreachable cluster looks like. Creating a managed cluster was not run: no cloud account was available, so the creation commands are from the documentation, not run here. The downloaded folder was deleted afterwards. Long error lines are shortened (the date and process number at the start of kubectl's log lines are removed). Related pages: containers, networking, IAM and the Kubernetes node access tutorial.
What Kubernetes is
Kubernetes runs containers across many machines and keeps them running the way you described. You do not start containers by hand: you give Kubernetes a desired state ("two copies of this image, reachable on port 80"), and controllers keep comparing reality with that description and fixing the difference. A copy dies, a machine fails, you change the image: the loop notices and acts. It is the same "declare, compare, reconcile" idea as Terraform, applied to running programs.
The parts, from the Kubernetes documentation (read on 6 October 2026):
- Control plane: the brain. The kube-apiserver (the HTTP API that everything talks to), etcd (the key-value store that holds all the state), the scheduler (decides which machine runs each new pod) and the controller manager (the reconciling loops).
- Nodes: the machines that run the work. Each runs the kubelet (starts and watches the containers), a container runtime and kube-proxy (the network rules for Services).
- Pod: the smallest unit, one or more containers that share an address. Deployment: "keep N identical pods running, and roll out new versions." Service: a stable address in front of changing pods. Namespace: a folder of objects inside a cluster. Manifest: a YAML file describing objects. kubeconfig: your client's list of clusters and credentials.
What "managed" hides
The documentation says it plainly: managed services (EKS, GKE, AKS) typically handle the entire control plane for you, and you manage the node side and your workloads. Running the control plane yourself (etcd backups, upgrades, certificates) is a job of its own. On a cloud, almost nobody should do it. Some newer modes go further and manage the nodes too.
The four managed services
- Azure: AKS (Azure Kubernetes Service). Azure creates and runs the control plane at no cost, and you pay for the nodes. Two modes: AKS Automatic (Azure manages nodes, scaling, upgrades and security defaults, and Microsoft recommends it for most new workloads) and AKS Standard (you manage node pools yourself, with the Free pricing tier by default; the Standard and Premium tiers are optional). It uses Microsoft Entra ID for access and Workload Identity for pods.
- AWS: EKS (Elastic Kubernetes Service). AWS manages the control plane. EKS standard leaves nodes to you (you choose EC2 instance types and capacity); EKS Auto Mode has EKS manage the nodes too. Pricing is per cluster and depends on Kubernetes version support, and you pay separately for the EC2 instances, EBS volumes and public IPv4 addresses that make up the nodes. Access uses AWS IAM together with Kubernetes permissions. It offers standard and extended support periods for each Kubernetes version.
- Google Cloud: GKE (Google Kubernetes Engine). Two modes: Autopilot (Google manages the cluster infrastructure, nodes and scaling) and Standard (you control the nodes). Google's advice: "Use Autopilot for most workloads, unless your application requires privileges or configuration options that don't meet the Autopilot constraints."
- Oracle Cloud: OKE (Oracle Kubernetes Engine). Node choices: virtual nodes (serverless: OKE automates scaling and patching), managed nodes (shared responsibility) and self-managed nodes (for special hardware such as GPUs). It integrates with OCI IAM and you generate your kubeconfig with oci ce cluster create-kubeconfig. (I could not read the page that explains basic against enhanced clusters, so check Oracle's documentation for the control plane features and fees.)
The pattern across all four: a mode where the cloud manages the nodes (AKS Automatic, EKS Auto Mode, GKE Autopilot, OKE virtual nodes) is easiest to run, and a mode where you manage node pools gives more control and more work. For a first cluster, take the managed-nodes mode unless you need something it does not allow.
Before you create anything
A cluster is the most expensive thing in this tutorial series to leave running. A checklist, with the pages that explain each item:
- An account and a budget alert (the account pages for each cloud). Note that free trials and always-free tiers do not cover a normal cluster: nodes, load balancers and control plane fees are billed or use your credit quickly.
- A region near you, and zones if you want resilience (the regions pages).
- Network space. In many setups pods get their own addresses from your network, so a cluster eats address space much faster than a few servers do. Plan a large enough range (see CIDR in the networking page).
- Identities. The cluster needs permission to create load balancers and disks, and people and pods need access to the cluster (the IAM page). The cloud usually creates a role for the cluster for you.
- The CLI signed in and kubectl installed (below). The CLI pages show the sign-in methods.
- A plan to delete it (the last section).
Creating a cluster (from the documentation, not run here)
The exact options change often: run the command with --help and read the current documentation. The shape of it on each cloud:
# Azure: AKS az group create --name lab-rg --location westeurope az aks create --resource-group lab-rg --name lab-aks --node-count 2 --generate-ssh-keys # AWS: EKS (eksctl is the EKS command-line tool) eksctl create cluster --name lab-eks --region eu-west-1 --nodes 2 # Google Cloud: GKE Autopilot gcloud container clusters create-auto lab-gke --region us-central1 # Oracle Cloud: OKE # the console's "Quick create" wizard is the easiest start: it builds the network, # the cluster and a node pool in one go
- Creating a cluster takes minutes (often ten or more) and creates several things behind your back: networks, roles, disks and load balancers. This is why a clean-up plan matters.
- For repeatable setups, use Terraform, which every one of these services supports.
- Oracle's CLI route needs many OCIDs (compartment, VCN, subnets), so most people start with the console wizard or Terraform.
Connecting: kubeconfig
kubectl is the Kubernetes command-line client. A cluster does not log you in by itself: each cloud has one command that writes the cluster and your credentials into your kubeconfig file (from the documentation, not run here):
az aks get-credentials --resource-group lab-rg --name lab-aks aws eks update-kubeconfig --name lab-eks --region eu-west-1 gcloud container clusters get-credentials lab-gke --region us-central1 oci ce cluster create-kubeconfig --cluster-id CLUSTER_OCID --file $HOME/.kube/config --region eu-frankfurt-1
After that, kubectl works. Now, how kubectl behaves. First, with nothing configured (real):
user@lab:~$ kubectl version --client Client Version: v1.37.1 Kustomize Version: v5.8.1 user@lab:~$ kubectl get pods E: "Couldn't get current server API group list" err="Get \"http://localhost:8080/api?timeout=32s\": dial tcp 127.0.0.1:8080: connect: connection refused" The connection to the server localhost:8080 was refused - did you specify the right host or port? user@lab:~$ ls ~/.kube 2>&1 | head -2 ls: cannot access '~/.kube': No such file or directory
- kubectl version --client printed the client version (v1.37.1) and the built-in Kustomize version, without needing a cluster.
- kubectl get pods with no kubeconfig fell back to localhost:8080, found nothing, and said "The connection to the server localhost:8080 was refused - did you specify the right host or port?" This is the classic message for "kubectl has no cluster configured".
- There is no ~/.kube folder yet. kubectl keeps its configuration in ~/.kube/config.
Contexts: which cluster am I talking to?
A kubeconfig holds three lists: clusters (an address and how to trust it), users (credentials) and contexts (a cluster, a user and a default namespace, under a name). kubectl always acts on the current context. I made two contexts with dummy servers (real output):
user@lab:~$ kubectl config set-cluster aks-lab --server=https://203.0.113.10:6443 --insecure-skip-tls-verify=true
Cluster "aks-lab" set.
user@lab:~$ kubectl config set-cluster gke-lab --server=https://203.0.113.20 --insecure-skip-tls-verify=true
Cluster "gke-lab" set.
user@lab:~$ kubectl config set-credentials lab-admin --token=EXAMPLE-TOKEN-NOT-REAL
User "lab-admin" set.
user@lab:~$ kubectl config set-context aks-dev --cluster=aks-lab --user=lab-admin --namespace=dev
Context "aks-dev" created.
user@lab:~$ kubectl config set-context gke-test --cluster=gke-lab --user=lab-admin --namespace=test
Context "gke-test" created.
user@lab:~$ kubectl config get-contexts
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
aks-dev aks-lab lab-admin dev
gke-test gke-lab lab-admin test
user@lab:~$ kubectl config use-context aks-dev
Switched to context "aks-dev".
user@lab:~$ kubectl config current-context
aks-dev
user@lab:~$ kubectl config view --minify
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://203.0.113.10:6443
name: aks-lab
contexts:
- context:
cluster: aks-lab
namespace: dev
user: lab-admin
name: aks-dev
current-context: aks-dev
kind: Config
users:
- name: lab-admin
user:
token: REDACTED
user@lab:~$ ls -l ~/.kube | sed 's/user/user/g'
total 4
-rw------- 1 user user 511 Oct 6 18:36 config
user@lab:~$ kubectl get pods
E: "Couldn't get current server API group list" err="Get \"https://203.0.113.10:6443/api?timeout=32s\": dial tcp 203.0.113.10:6443: i/o timeout"
Unable to connect to the server: dial tcp 203.0.113.10:6443: i/o timeout
Read it:
- set-cluster, set-credentials and set-context built the entries, and get-contexts lists them: aks-dev (cluster aks-lab, namespace dev) and gke-test (cluster gke-lab, namespace test).
- use-context aks-dev switched, and current-context confirms it. Before any command that changes or deletes, check kubectl config current-context. Running a delete against the wrong cluster is a classic mistake, and a prompt that shows the current context helps.
- kubectl config view --minify shows only the active context. The token is shown as REDACTED: kubectl hides secrets when printing. The file itself is mode 600, because it holds credentials (here a dummy token, but the real ones are real: treat ~/.kube/config like a password file).
- The last command shows what an unreachable cluster looks like: kubectl waits and then reports "Unable to connect to the server: dial tcp 203.0.113.10:6443: i/o timeout" (the log line shows the request has a 32-second timeout). With a real cluster, a timeout like this usually means a private API endpoint (you need a VPN or a bastion), an IP allow-list that does not include you, or a firewall rule.
More than one kubeconfig file
kubectl config --help explains the loading rules (real output): --kubeconfig loads only that file; the KUBECONFIG environment variable is a list of files that are merged; otherwise ~/.kube/config. With two files:
user@lab:~$ ls kc
a.yaml
b.yaml
user@lab:~$ KUBECONFIG=kc/a.yaml kubectl config get-contexts
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
ctx-a c-a u-a
user@lab:~$ KUBECONFIG=kc/a.yaml:kc/b.yaml kubectl config get-contexts
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
ctx-a c-a u-a
ctx-b c-b u-b
user@lab:~$ kubectl get --help | head -n 8
Display one or many resources.
Prints a table of the most important information about the specified resources. You can filter the list using a label selector and the --selector flag. If the desired resource type is namespaced you will only see results in the current namespace if you don't specify any namespace.
By specifying the output as 'template' and providing a Go template as the value of the --template flag, you can filter the attributes of the fetched resources.
Use "kubectl api-resources" for a complete list of supported resources.
user@lab:~$ kubectl config --help | sed -n 1,12p
Modify kubeconfig files using subcommands like "kubectl config set current-context my-context".
The loading order follows these rules:
1. If the --kubeconfig flag is set, then only that file is loaded. The flag may only be set once and no merging takes place.
2. If $KUBECONFIG environment variable is set, then it is used as a list of paths (normal path delimiting rules for your system). These paths are merged. When a value is modified, it is modified in the file that defines the stanza. When a value is created, it is created in the first file that exists. If no files in the chain exist, then it creates the last file in the list.
3. Otherwise, ${HOME}/.kube/config is used and no merging takes place.
Available Commands:
current-context Display the current-context
delete-cluster Delete the specified cluster from the kubeconfig
delete-context Delete the specified context from the kubeconfig
With KUBECONFIG=kc/a.yaml one context is visible, and with KUBECONFIG=kc/a.yaml:kc/b.yaml both appear. That is a clean way to keep one file per cluster (for example one per cloud account) and combine them only when you need to.
Writing manifests
Everything in Kubernetes is an object described in YAML. You rarely write one from a blank page: kubectl create ... --dry-run=client -o yaml prints the manifest it would create, without creating anything. These ran for real, with no cluster:
user@lab:~$ kubectl create namespace lab --dry-run=client -o yaml
apiVersion: v1
kind: Namespace
metadata:
name: lab
spec: {}
status: {}
user@lab:~$ kubectl create deployment web --image=nginx:1.27 --replicas=2 --port=80 --dry-run=client -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: web
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
strategy: {}
template:
metadata:
labels:
app: web
spec:
containers:
- image: nginx:1.27
name: nginx
ports:
- containerPort: 80
resources: {}
status: {}
user@lab:~$ kubectl create service loadbalancer web --tcp=80:80 --dry-run=client -o yaml
apiVersion: v1
kind: Service
metadata:
labels:
app: web
name: web
spec:
ports:
- name: 80-80
port: 80
protocol: TCP
targetPort: 80
selector:
app: web
type: LoadBalancer
status:
loadBalancer: {}
- A Namespace is almost nothing: a name.
- The Deployment for nginx:1.27 has the fields to understand: replicas: 2 (two pods), selector.matchLabels and template.metadata.labels (the label app: web that ties the Deployment to its pods: they must match), and the containers list with the image and port. (Note the empty resources: {}: in real life set CPU and memory requests and limits, or the scheduler and your bill cannot plan.)
- The Service of type LoadBalancer selects pods labelled app: web. On a managed cluster, this type makes the cloud create a real load balancer, which bills by the hour (see the services and networking pages) and has its own public address: use ClusterIP for anything internal.
- Save these to files and keep them in version control. That is "infrastructure as code" for your applications. (Pin the image tag, as the containers page says: nginx:1.27, not latest.)
Applying them: even a "dry run" talks to the cluster
With the saved web.yaml and the unreachable cluster still selected:
user@lab:~$ ls svc.yaml web.yaml user@lab:~$ kubectl apply -f web.yaml --dry-run=client error: error validating "web.yaml": error validating data: failed to download openapi: Get "https://203.0.113.10:6443/openapi/v2?timeout=32s": dial tcp 203.0.113.10:6443: i/o timeout; if you choose to ignore these errors, turn validation off with --validate=false user@lab:~$ kubectl apply -f web.yaml --dry-run=client --validate=false E: "Couldn't get current server API group list" err="Get \"https://203.0.113.10:6443/api?timeout=32s\": dial tcp 203.0.113.10:6443: i/o timeout" error: unable to recognize "web.yaml": Get "https://203.0.113.10:6443/api?timeout=32s": dial tcp 203.0.113.10:6443: i/o timeout user@lab:~$ kubectl apply -f web.yaml --dry-run=server error: error validating "web.yaml": error validating data: failed to download openapi: Get "https://203.0.113.10:6443/openapi/v2?timeout=32s": dial tcp 203.0.113.10:6443: i/o timeout; if you choose to ignore these errors, turn validation off with --validate=false
kubectl apply --dry-run=client still tried to download the cluster's API description (openapi) to validate the file, and failed after the timeout. With --validate=false it then failed on discovery ("unable to recognize"). The lesson: apply needs the cluster, even for a dry run, and --dry-run=server (the more faithful check, which asks the real API server what it would do) needs it even more. To generate files with no cluster, use kubectl create ... --dry-run=client -o yaml as above. Then kubectl apply -f web.yaml creates or updates the objects (from the documentation, not run here), and kubectl get pods and kubectl get svc show them.
Kustomize: one base, many environments
kubectl has Kustomize built in (the version line above says v5.8.1), and it needs no cluster. A base holds the common manifests and an overlay per environment changes only what differs. This ran for real (kz/base/deployment.yaml is the Deployment above):
user@lab:~$ cat kz/prod/kustomization.yaml
resources:
- ../base
namespace: prod
replicas:
- name: web
count: 5
images:
- name: nginx
newTag: "1.27.2"
user@lab:~$ kubectl kustomize kz/prod
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: web
name: web
namespace: prod
spec:
replicas: 5
selector:
matchLabels:
app: web
strategy: {}
template:
metadata:
labels:
app: web
spec:
containers:
- image: nginx:1.27.2
name: nginx
ports:
- containerPort: 80
resources: {}
status: {}
The overlay prod/kustomization.yaml says: use the base, put everything in namespace prod, set replicas to 5 and the image tag to 1.27.2. kubectl kustomize kz/prod printed the combined result: the same Deployment with namespace: prod, replicas: 5 and image: nginx:1.27.2. To apply an overlay, kubectl apply -k kz/prod (from the documentation, not run here). It is the simplest, built-in way to avoid copying YAML for dev, test and prod.
Secrets: not secret by default
A Kubernetes Secret holds a password or a key. Making one without a cluster (real):
user@lab:~$ kubectl create secret generic db --from-literal=password=S3cret-demo --dry-run=client -o yaml apiVersion: v1 data: password: UzNjcmV0LWRlbW8= kind: Secret metadata: name: db user@lab:~$ echo UzNjcmV0LWRlbW8= | base64 -d; echo S3cret-demo
The Secret manifest shows password: UzNjcmV0LWRlbW8=, and base64 -d turns it back into S3cret-demo in one command. Base64 is an encoding, not encryption. The Kubernetes documentation (read on 6 October 2026) says it directly: Secrets are stored unencrypted in etcd by default, and base64 "provides no confidentiality". Its advice, in order:
- Turn on encryption at rest for Secrets in etcd (on managed services this is a setting or already on: check yours).
- Least-privilege RBAC. Whoever can list Secrets can read them, and whoever can run a pod that mounts a Secret can read it too.
- Use an external secret store and let the cluster fetch from it (the cloud's own: Key Vault, Secrets Manager, Secret Manager, Vault, as the services maps list), through the Secrets Store CSI driver.
- Prefer mounting Secrets as files to passing them as environment variables, and mount them only into the containers that need them.
- Never commit a Secret manifest to source control.
Access and identity
- People: the cluster uses the cloud's identity system: Microsoft Entra ID with Azure RBAC for Kubernetes (AKS), AWS IAM together with Kubernetes permissions (EKS), Google Cloud IAM (GKE), OCI IAM (OKE). Inside the cluster, Kubernetes RBAC (roles and role bindings) decides what a user may do in which namespace. The ideas on the IAM page apply twice: once for the cloud, once for the cluster.
- Pods: a pod that must call a cloud service should not carry a key. Each cloud has a way to give a pod an identity: Workload Identity on AKS (it is on by default in Automatic mode), Kubernetes service accounts mapped to AWS roles on EKS, Workload Identity on GKE and OKE (the machine-identity idea again).
What it costs, and how to clean up
- Nodes bill while they exist, used or idle. AKS's control plane is free; EKS charges per cluster; check GKE's and OKE's pricing pages. Load balancers, public IPs, disks and NAT gateways bill separately (the services and networking pages list them).
- A Service of type LoadBalancer creates a cloud load balancer, and deleting the cluster without deleting the Service first can leave that load balancer behind on some setups. Delete the Services (and persistent volumes) first, then the cluster, then check the cloud's console for leftovers.
- Clean-up commands (from the documentation, not run here):
kubectl delete -f web.yaml az group delete --name lab-rg --yes --no-wait eksctl delete cluster --name lab-eks --region eu-west-1 gcloud container clusters delete lab-gke --region us-central1
(For OKE, delete the cluster and its node pool from the console or CLI, and then the VCN it created. If you used Terraform, terraform destroy removes everything it made.) Check the billing page the next day.
When it does not work
- "connection refused ... localhost:8080": no kubeconfig. Run the cloud's get-credentials command.
- A timeout (as in the real example): the API endpoint is private, your address is not allowed, or a firewall rule is in the way.
- Wrong cluster: kubectl config current-context.
- A pod stays Pending: not enough capacity (nodes full, or a resource request too big). kubectl describe pod NAME shows the reason at the bottom.
- ImagePullBackOff: the node cannot pull the image: wrong name or tag, or no permission to the registry (the containers page explains registry access).
- CrashLoopBackOff: the container starts and dies. kubectl logs NAME and kubectl logs NAME --previous.
- kubectl and the cluster differ much in version: Kubernetes supports a limited difference between client and server (check the version-skew policy), so keep kubectl close to your cluster's version.
(Those commands are from the documentation, not run here. kubectl describe, logs and get need a cluster.)
Common mistakes
- Leaving a cluster running after the lesson.
- Not checking the context before delete or apply.
- latest image tags, and no resource requests or limits.
- Secrets in Git, or believing base64 hides them.
- A public API endpoint open to everyone: restrict it (allowed ranges, or a private endpoint).
- Too small an address range for pods.
- Creating Services of type LoadBalancer for everything instead of one ingress or gateway.
- Putting a database in a pod with no plan for storage and backups: use the cloud's managed database unless you know why not.
What this page did not cover
Ingress and gateways, persistent storage in depth, autoscaling, Helm, service meshes, upgrades and node pools, network policies, GitOps tools, and running a cluster yourself (kubeadm, k3s). The node access tutorial covers reaching the nodes, and the Kubernetes documentation covers the rest.
The cheat sheet
- Parts: control plane (API server, etcd, scheduler, controllers) managed by the cloud; nodes (kubelet, runtime) managed by you, or by the cloud in the "auto" modes. Objects: pod, Deployment, Service, Namespace.
- Services: AKS (Automatic or Standard, control plane free), EKS (per-cluster price, Auto Mode), GKE (Autopilot or Standard), OKE (virtual, managed or self-managed nodes).
- Create: az aks create | eksctl create cluster | gcloud container clusters create-auto | the OKE console wizard. Connect: az aks get-credentials | aws eks update-kubeconfig | gcloud container clusters get-credentials | oci ce cluster create-kubeconfig.
- kubeconfig: ~/.kube/config (mode 600, a secret). kubectl config get-contexts, use-context NAME, current-context, view --minify. KUBECONFIG=a:b merges files. localhost:8080 refused means no config.
- Manifests: kubectl create ... --dry-run=client -o yaml > file.yaml, kubectl apply -f file.yaml (needs the cluster), kubectl kustomize DIR and kubectl apply -k DIR.
- Secrets: base64 is not encryption. Encrypt at rest, RBAC, external store, no Git.
- Debug: kubectl get, describe, logs, logs --previous.
- Clean up: delete Services first, then the cluster (az group delete, eksctl delete cluster, gcloud container clusters delete), then check for leftovers.
← Containers on the Cloud: Images, Registries and Serverless Containers · ↑ All tutorials · EC2 From Scratch: A Server You Can Trust →