This is Part 2 of a series on running Kubernetes at personal scale. In Part 1, we covered the rationale: Kubernetes is worth running even for low-traffic, single-user workloads because it makes installing third-party apps as simple as helm install and gives you strong infrastructure-as-code foundations. Here we walk through the full setup — from launching an EC2 instance to deploying a real application.
Our target environment is a single t2.small EC2 instance (2 GB RAM, 1 vCPU, ~$15/month) running Ubuntu 18.04. We will use KIND (Kubernetes IN Docker) to run the cluster, add cert-manager and nginx-ingress for TLS and routing, and finish by deploying HackMD — an open-source collaborative Markdown editor — as a proof of concept.
Set Up a Machine
These instructions target AWS EC2, but the same approach works on any cloud provider or bare-metal host that can run Docker.
Launch the Instance
- Open the EC2 console and click Launch Instance.
- Choose Ubuntu Server 18.04 LTS as the AMI.
- Select the t2.small instance type. Do not go smaller — Kubernetes has significant baseline memory overhead. If you plan to run memory-hungry workloads (databases, CI runners, etc.) consider t2.medium or larger.
- Increase the root volume from the default if you intend to store significant data on the node.
- In the security group, open port 22 (SSH), port 80 (HTTP), and port 443 (HTTPS).
- Download and store your SSH key pair safely.
Assign an Elastic IP
EC2 instances get a new public IP every time they restart. An Elastic IP gives your instance a stable address that you can map to a DNS name.
- In the EC2 console, go to Elastic IPs → Allocate Elastic IP address.
- Associate the new address with your new instance.
Create DNS Records
Head to Route 53 (or your DNS provider) and create two A records:
*.example.com→ your Elastic IP (wildcard record covers every subdomain)example.com→ your Elastic IP (optional, for the apex domain)
With DNS in place, every subdomain you configure on the cluster will automatically resolve to your server.
Install Dependencies
SSH into your instance, then install the following tools in order.
Docker
KIND runs Kubernetes inside Docker containers, so Docker is the foundational dependency.
sudo apt update
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable"
sudo apt update
sudo apt install -y docker-ce
sudo usermod -aG docker $USER
newgrp dockerCode language: PHP (php)
KIND
KIND (Kubernetes IN Docker) creates a fully functional Kubernetes cluster using Docker containers as nodes. This is ideal for a single-machine setup.
curl -Lo ./kind https://github.com/kubernetes-sigs/kind/releases/download/v0.5.1/kind-$(uname)-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kindCode language: JavaScript (javascript)
kubectl
kubectl is the primary CLI for communicating with any Kubernetes cluster. It reads your kubeconfig to know which cluster to talk to.
curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x ./kubectl
sudo mv ./kubectl /usr/local/bin/kubectlCode language: JavaScript (javascript)
Helm
Helm is the package manager for Kubernetes. Helm charts package up all the manifests needed to deploy an application (WordPress, Ghost, MySQL, GitLab, etc.) into a single, versioned, configurable unit.
sudo snap install helm --classic
Reckoner
Reckoner is a Python tool that lets you declare multiple Helm releases in a single YAML file (called a course file), making it easier to manage several charts together with shared configuration and install hooks.
sudo apt-get install -y python3 python3-pip
export PATH=$PATH:~/.local/bin/
pip3 install reckonerCode language: JavaScript (javascript)
Dependency Summary
- Docker — runs isolated containers (and hosts the KIND cluster nodes).
- KIND — bootstraps a Kubernetes control plane and worker node inside Docker.
- kubectl — CLI for managing Kubernetes resources.
- Helm — installs and upgrades packaged Kubernetes applications.
- Reckoner — manages multiple Helm charts declaratively from a single YAML file.
Build the Cluster
Create a file called cluster.yaml. Storing this in version control is strongly recommended — it is a complete description of your cluster topology.
cluster.yaml
kind: Cluster
apiVersion: kind.sigs.k8s.io/v1alpha3
nodes:
- role: control-plane
- role: worker
extraMounts:
- containerPath: /opt/local-path-provisioner
hostPath: /home/ubuntu/kind-disk
extraPortMappings:
- containerPort: 80
hostPort: 80
listenAddress: "0.0.0.0"
- containerPort: 443
hostPort: 443
listenAddress: "0.0.0.0"Code language: JavaScript (javascript)
Key decisions in this config:
- Two roles — a
control-planenode runs the Kubernetes API server, scheduler, and controller manager; theworkernode runs your application pods. - Persistent storage mount —
/home/ubuntu/kind-diskon the host is mounted into the worker container so database volumes survive pod restarts. Adjust this path to suit your environment. - Port mappings — ports 80 and 443 are forwarded from the host into the worker container so external HTTP/HTTPS traffic reaches the cluster.
Create the cluster and point kubectl at it:
kind create cluster --config cluster.yaml
export KUBECONFIG="$(kind get kubeconfig-path --name='kind')"Code language: JavaScript (javascript)
Verify both nodes are ready (this can take a minute or two):
kubectl get nodesCode language: JavaScript (javascript)
Expected output:
NAME STATUS ROLES AGE VERSION
kind-control-plane Ready master 109s v1.15.3
kind-worker Ready <none> 73s v1.15.3Code language: CSS (css)
Configure the Cluster
Install Tiller (Helm’s In-Cluster Component)
Helm 2 requires a server-side component called Tiller. We create a dedicated namespace and give it cluster-admin privileges via RBAC.
tiller.rbac.yaml
apiVersion: v1
kind: Namespace
metadata:
name: tiller
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: tiller
namespace: tiller
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
name: tiller-clusterrolebinding
subjects:
- kind: ServiceAccount
name: tiller
namespace: tiller
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: ""Code language: JavaScript (javascript)
kubectl apply -f tiller.rbac.yaml
export TILLER_NAMESPACE=tiller
helm init --service-account tillerCode language: JavaScript (javascript)
Configure Persistent Storage
KIND ships with a default standard storage class that does not persist data across container restarts. We replace it with Rancher’s local-path-provisioner, which writes data to the host directory we mounted in cluster.yaml.
# Install the local-path-provisioner
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml
# Remove the default flag from the built-in 'standard' storage class
kubectl patch storageclass standard -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"false", "storageclass.beta.kubernetes.io/is-default-class":"false"}}}'
# Make local-path the new default
kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true", "storageclass.beta.kubernetes.io/is-default-class":"true"}}}'Code language: PHP (php)
Install cert-manager and nginx-ingress
These two components handle the two things every public web service needs: TLS certificates and HTTP routing.
- cert-manager — automatically provisions and renews TLS certificates from Let’s Encrypt.
- nginx-ingress — proxies incoming HTTP/HTTPS requests to the correct Kubernetes service based on the hostname.
First, create issuer.yaml to tell cert-manager to use the Let’s Encrypt ACME v2 API. Replace you@example.com with your real email address (Let’s Encrypt uses it for expiry notices).
issuer.yaml
apiVersion: cert-manager.io/v1alpha2
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: you@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
Do not apply this manifest yet — cert-manager’s CRDs must exist first. Next, create core.course.yaml for Reckoner. The post_install hook on cert-manager applies issuer.yaml automatically after the chart is ready.
core.course.yaml
repositories:
jetstack:
url: https://charts.jetstack.io
namespace: default
charts:
cert-manager:
hooks:
pre_install:
- kubectl apply --validate=false -f https://raw.githubusercontent.com/jetstack/cert-manager/release-0.11/deploy/manifests/00-crds.yaml
post_install:
- sleep 30 && kubectl apply -f issuer.yaml
namespace: cert-manager
repository: jetstack
nginx-ingress:
namespace: nginx-ingress
values:
controller:
hostNetwork: true
service:
type: LoadBalancerCode language: JavaScript (javascript)
Run Reckoner to install both charts:
reckoner plot core.course.yamlCode language: CSS (css)
Verify the pods are running (allow a minute or two for images to pull):
$ kubectl get pods -n nginx-ingress
NAME READY STATUS RESTARTS AGE
nginx-ingress-controller-664b77978-gbd59 1/1 Running 0 14s
nginx-ingress-default-backend-576b86996d-fjb4k 1/1 Running 0 14s
$ kubectl get pods -n cert-manager
NAME READY STATUS RESTARTS AGE
cert-manager-6b78b7c997-mblt4 1/1 Running 0 18s
cert-manager-cainjector-54c4796c5d-psdgt 1/1 Running 0 18s
cert-manager-webhook-77ccf5c8b4-8tkwt 1/1 Running 0 17sCode language: JavaScript (javascript)
Install an Application: HackMD
To validate the full stack, we’ll deploy HackMD (now known as CodiMD/HedgeDoc), an open-source, real-time collaborative Markdown editor. It is a lightweight, self-hosted alternative to Google Docs, and it stores all data in a PostgreSQL database that will live in your kind-disk directory.
Create the Ingress
The Ingress resource tells nginx-ingress how to route traffic for hackmd.example.com and instructs cert-manager to obtain a TLS certificate. Replace both occurrences of example.com with your real domain.
ingress.yaml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
kubernetes.io/tls-acme: "true"
name: hackmd
namespace: hackmd
spec:
rules:
- host: hackmd.example.com
http:
paths:
- backend:
serviceName: hackmd
servicePort: 3000
path: /
tls:
- secretName: tls-prod-cert
hosts:
- hackmd.example.com
Create the Reckoner App Course File
Add HackMD to a separate course file for application-layer charts. As you deploy more apps, add them here.
apps.course.yaml
namespace: default
charts:
hackmd:
namespace: hackmd
hooks:
post_install:
- kubectl apply -f ./ingress.yamlCode language: JavaScript (javascript)
Deploy it:
reckoner plot apps.course.yamlCode language: CSS (css)
After a few moments, HackMD should be accessible at https://hackmd.example.com. cert-manager will automatically obtain a Let’s Encrypt certificate. Data will be stored under /home/ubuntu/kind-disk on the host.
Adding More Applications
The pattern for every future application is the same:
- Add a chart entry to
apps.course.yaml. - Create a corresponding
ingress.yamlpointing to your domain. - Run
reckoner plot apps.course.yaml.
Because all your configuration lives in YAML files committed to Git, you can recreate the entire cluster — including all applications — on a new machine by replaying the same commands. This is the core value proposition of infrastructure as code at personal scale.
Summary
| Component | Role |
|---|---|
| KIND | Runs a Kubernetes cluster inside Docker on a single host |
| kubectl | CLI for all cluster interactions |
| Helm + Tiller | Installs and manages packaged Kubernetes applications |
| Reckoner | Declares multiple Helm releases in one YAML file |
| local-path-provisioner | Provides persistent storage backed by a host directory |
| nginx-ingress | Routes external HTTP/HTTPS traffic to the right service |
| cert-manager | Automatically provisions and renews Let’s Encrypt TLS certs |
Running Kubernetes at personal scale adds some operational complexity, but it pays off quickly: every application you self-host follows the same installation and upgrade pattern, your infrastructure is fully version-controlled, and migrating to a larger machine requires nothing more than replaying your playbooks.
Original article sourced from containerjournal.com (2019).