Secure your Civo Kubernetes API with a Bastion host
Learn how to secure the Civo Kubernetes API by routing kubectl through a bastion host and restricting API access with firewall rules. This tutorial covers SSH tunneling, firewall configuration, and production security best practices.
Written by
Founder at KubeNine
Written by
Founder at KubeNine
When you spin up a Kubernetes cluster on Civo and run civo kubernetes config, you get a kubeconfig that points kubectl at something like https://212.x.x.x:6443. That address is a public IP, and on a typical cluster created with Civo's default firewall rules, port 6443 is open to 0.0.0.0/0.
For most teams that's one open door too many. The Kubernetes API server is the control plane for everything you run — anyone who can reach it gets to try their luck against your authentication. You almost certainly don't want the entire internet able to make that attempt.
In this tutorial we'll close that door without breaking your own access. The plan is two moves:
- Restrict the API server's firewall rule so only one machine — a small, hardened bastion — can reach port 6443.
- Route your
kubectltraffic through that bastion over SSH, so your laptop reaches the API through the allowlisted host instead of directly.
The result is an API server that's effectively private: a port scan from any random host finds nothing, but your team still runs kubectl get nodes as comfortably as before. Everything below uses the civo CLI, OpenSSH, and kubectl — no extra agents or VPN software to babysit.
Region note: Make sure your CLI is pointed at the same region as your cluster. Run civo region ls and set it with civo region current <REGION>, or append --region <REGION> to each command below.
Architecture overview

Step 0: See what's actually exposed
Before changing anything, confirm the problem. Grab the API endpoint from your kubeconfig:
kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}'# https://212.x.x.x:6443
Now, from a machine that isn't on your cluster — a throwaway VM in another cloud, or even your phone's hotspot — scan that port:
nmap -Pn -p 6443 212.x.x.x# PORT STATE SERVICE# 6443/tcp open sun-sr-https
Open, to the world. That's our starting point.
Step 1: Create a hardened bastion
The bastion is the only host that will be allowed to talk to the API. Keep it small — it does nothing but forward your SSH traffic. A g3.xsmall instance is plenty, and because egress on Civo is free, this stays cheap to run around the clock.
First, find the network your cluster lives in so the bastion can share it:
civo kubernetes show my-cluster -o json | jq -r '.network_id'# 28244c7d-b1b9-48cf-9727-aebb3493aaac
On PowerShell without jq: (civo kubernetes show my-cluster -o json | ConvertFrom-Json).network_id
Instances launched on a non-default network must have a firewall attached. Create one for the bastion with SSH allowed. Use --no-default-rules so Civo does not also open ports 80 and 443 on the bastion:
civo firewall create bastion-fw --network <cluster-network-id> --no-default-rulescivo firewall rule create bastion-fw \--protocol TCP \--startport 22 \--endport 22 \--cidr 0.0.0.0/0 \--direction ingress \--label ssh
Then create the instance in that network with key-only SSH:
civo instance create bastion \--size g3.xsmall \--network <cluster-network-id> \--firewall bastion-fw \--sshkey my-key \--initialuser civo
Putting the bastion in the cluster's network isn't strictly required for API access — the control-plane endpoint is reached over its public IP regardless — but it's good hygiene and lets the bastion also reach NodePorts and internal services privately later.
Grab its public IP; this is the address we'll allowlist:
civo instance show bastion -o custom -f public_ip# 74.x.x.x
Harden it before it's the only way in. At minimum: disable password auth, and enable fail2ban for SSH:
ssh civo@74.x.x.xsudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_configsudo systemctl restart sshsudo apt-get update && sudo apt-get install -y fail2bansudo systemctl enable --now fail2ban
Consider tightening the bastion firewall's SSH rule from 0.0.0.0/0 to your office or home IP once you have confirmed everything works.
Step 2: Tighten the firewall — without locking yourself out
This is the step where people accidentally cut their own access, so do it in the safe order: add the new narrow rule first, verify, then remove the wide-open one.
Find the firewall attached to your cluster. Civo names it Kubernetes cluster: <cluster-name>:
civo kubernetes show my-cluster -o custom -f Firewall# Kubernetes cluster: my-clustercivo firewall lscivo firewall rule ls "Kubernetes cluster: my-cluster"
You'll see a rule allowing TCP 6443 from 0.0.0.0/0. Add a replacement scoped to the bastion's IP (use the firewall name from the previous command — quote it if it contains spaces):
civo firewall rule create "Kubernetes cluster: my-cluster" \--protocol TCP \--startport 6443 \--endport 6443 \--cidr 74.x.x.x/32 \--direction ingress \--label kubernetes-api-bastion
Confirm the bastion can still reach the API before you remove anything:
ssh civo@74.x.x.x 'nc -vz 212.x.x.x 6443'# Connection to 212.x.x.x 6443 port [tcp/*] succeeded!
If nc is not installed on the bastion, use curl instead (a 401 Unauthorized response still means the port is reachable):
ssh civo@74.x.x.x 'curl -vk --connect-timeout 5 https://212.x.x.x:6443'
Only now remove the 6443 rule that still allows 0.0.0.0/0. Leave any 80/443 rules alone if your cluster uses them for ingress traffic. Get the 6443 rule ID from the rule ls output and delete it:
civo firewall rule remove "Kubernetes cluster: my-cluster" <6443-open-rule-id> -y
Don't skip the verify step. If you remove the 0.0.0.0/0 rule on 6443 and your bastion IP was typo'd, you've locked everyone out of the control plane. The Civo dashboard's firewall editor is a fine fallback to fix a bad rule in a hurry.
Step 3: Route kubectl through the bastion
The firewall now rejects everyone but the bastion, including your laptop. We restore access by tunneling kubectl's traffic through SSH.
The clean way is a SOCKS proxy. One reason to prefer it on Civo specifically: the API server's TLS certificate is issued for the control plane endpoint in your kubeconfig — typically the public IP, and sometimes the cluster's *.k8s.civo.com DNS name as well. A SOCKS proxy lets kubeconfig keep talking to that same server URL — so the certificate still validates — while the packets travel through the bastion.
Open the proxy in a terminal (leave it running):
ssh -D 1080 -q -N civo@74.x.x.x
Then point kubeconfig at it. Edit your cluster entry and add a single proxy-url line:
clusters:- cluster:server: https://212.x.x.x:6443certificate-authority-data: LS0tLS1CRUdJ...proxy-url: socks5://localhost:1080name: my-cluster
That's it — kubectl now dials the API through the bastion:
# k3s-node-pool-xxxx Ready <none> 3d v1.30.x
If your kubeconfig uses the cluster DNS entry (*.k8s.civo.com) instead of the raw IP, use socks5h:// so DNS resolution happens on the bastion:
proxy-url: socks5h://localhost:1080
Sidebar: If you can't use proxy-url
Older tooling or wrappers that ignore proxy-url can use a local port-forward instead:
ssh -L 6443:212.x.x.x:6443 -N civo@74.x.x.x
Now kubectl must target localhost, but the cert is for 212.x.x.x — so tell kubectl which name to validate against:
kubectl --server=https://localhost:6443 --tls-server-name=212.x.x.x get nodes
You can bake tls-server-name: 212.x.x.x and server: https://localhost:6443 into the kubeconfig cluster block to avoid the flags. It works, but it leaks an extra moving part into every context — which is why SOCKS is the default recommendation.
Step 4: Prove it's locked down
Repeat the Step 0 scan from that same outside machine:
nmap -Pn -p 6443 212.x.x.x# PORT STATE SERVICE# 6443/tcp filtered sun-sr-https
Filtered — the firewall is silently dropping the packets. And kubectl from a non-allowlisted machine (proxy off) now hangs and times out:
kubectl get nodes# Unable to connect to the server: dial tcp 212.x.x.x:6443: i/o timeout
Turn the SOCKS proxy back on and it works again. That's the whole point: the API is unreachable from the internet, fully reachable through the one path you control.
Giving the whole team access
A single shared bastion scales to a team without touching the firewall again. The firewall rule stays pinned to the bastion's one IP; you grant people access by adding their SSH public keys to the bastion:
ssh-copy-id -i ~/.ssh/id_ed25519.pub civo@74.x.x.x
On Windows without ssh-copy-id, append the public key manually: type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh civo@74.x.x.x "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
Each engineer runs their own ssh -D 1080 … and shares the same kubeconfig proxy-url. You get one place to audit who can reach the cluster (the bastion's authorized_keys and auth logs), and onboarding or offboarding someone never means editing a firewall rule. Compare that to allowlisting every laptop's IP directly — which breaks the moment someone's home IP rotates and turns the firewall rule into a junk drawer.
What this does and doesn't buy you
You've removed your API server from the public internet's reach and funneled all access through a single hardened, auditable choke point. That eliminates a huge class of drive-by exposure — internet-wide scanners, opportunistic auth attempts, and zero-days against the API server can no longer touch it.
It is not the whole story, and it shouldn't be your only control:
- RBAC still matters: Anyone who gets onto the bastion can reach the API, so their kubeconfig credentials and Kubernetes RBAC are still what stands between them and your workloads. Network restriction is defense in depth, not a replacement for least-privilege access.
- The bastion is now critical infrastructure: Patch it, monitor its SSH logs, and key-only is non-negotiable. A compromised bastion is a compromised path to your control plane.
- CI/CD needs a path too: Pipelines that run
kubectlwill need to tunnel through the bastion (a CI runner with its own key) or get their own narrow allowlist entry. - OIDC and credential plugins may need extra configuration. If you use an exec-based auth plugin, you may need to set
HTTPS_PROXYin the plugin'senvblock so token requests also route through the SOCKS proxy.
For the cost of one of Civo's smallest instances, though, you've turned a wide-open control plane into one that answers only to you. Run the Step 0 scan against your own clusters today — you may be surprised how many are still wide open.
FAQs
Summary
A Kubernetes API server doesn't need to be reachable from the entire internet. By allowing access only through a hardened bastion host and routing kubectl traffic over SSH, you significantly reduce your cluster's attack surface without changing your daily workflow.
This approach doesn't replace Kubernetes RBAC or strong authentication, but it adds an important layer of protection that's simple to implement. If you're running production workloads on Civo Kubernetes, securing the API server with a bastion host is a practical step that's well worth taking.
Read more

Founder at KubeNine
Vikas Yadav is the founder of Kubenine and a DevOps engineer focused on building scalable cloud infrastructure and developer platforms. His work centers on automation, platform engineering, and modern DevOps practices using technologies such as AWS, Kubernetes, Terraform, and CI/CD pipelines.
With more than seven years of experience in cloud engineering, including previous work at LinkedIn, Vikas helps startups and enterprise teams improve deployment workflows, automate infrastructure, and optimize cloud environments.
Share this article
Further Reading
21 January 2025
How to mitigate Kubernetes runtime security threats
24 November 2025