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.

8 minutes reading time

Written by

Vikas Yadav
Vikas Yadav

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:

  1. Restrict the API server's firewall rule so only one machine — a small, hardened bastion — can reach port 6443.
  2. Route your kubectl traffic 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

Secure Your Civo Kubernetes API with a Bastion Host

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-rules
civo 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.x
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart ssh
sudo apt-get update && sudo apt-get install -y fail2ban
sudo 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-cluster
civo firewall ls
civo 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:6443
certificate-authority-data: LS0tLS1CRUdJ...
proxy-url: socks5://localhost:1080
name: 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

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 kubectl will 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_PROXY in the plugin's env block 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

Vikas Yadav
Vikas Yadav

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.

View author profile