This series is also available in French.
Lire en Français
Chapter 5
Home LabTutorialsDevOps & Infrastructure

How to deploy a simple application to Kubernetes

6 min read
How to deploy a simple application to Kubernetes
Learn how to deploy a basic HTML application to your local Kubernetes cluster. This guide covers building a Docker image, pushing to GitHub Container Registry, and writing essential YAML configurations.

Now that Kubernetes is up and running, it's time to do something with it. For me, it’s this very blog, which is already running on Kubernetes on my Raspberry Pi as I write this. I’ve tried to do this in the most professional way possible. Not only to learn but also to share with you; let me explain how I deployed this very blog on Kubernetes.

To properly understand the whole process (from building the image to DNS and HTTPS) is a complex endeavour. This is why we will focus first on deploying a very simple website on your local network.

Step 1 : The simplest of apps

To simplify this tutorial, let’s set up the simplest app possible: a single HTML file served with Nginx and built with a Dockerfile.

Simplest app file tree

The Dockerfile looks like this:

FROM nginx:alpine
COPY app/ /usr/share/nginx/html/

And to make it even simpler, the HTML file isn't even "proper" HTML, but browsers don’t mind. It’s just this:

<h1>Hello from the simplest possible app!</h1>

Since the goal is to learn deployment rather than app development, this will allow us to focus on the essentials. You can build and test it locally using the following commands:

docker build -t simple-app .

And run it locally to test:

docker run -p 80:80 simple-app

Once running, you can access your website by visiting http://localhost in your browser.

Step 2 : How to push your image to GitHub Container Registry

Great, we have an app; it's time to make it available to our cluster. To do this, Kubernetes needs to pull the image from a registry. There are two options: either you go all in self hosting (with Docker Registry or Zot), or you can use the GitHub Container Registry (GHCR). To make this tutorial more applicable to everyone, let’s use GitHub, but don't hesitate to try and self-host your container as well!

First, you'll need a Personal Access Token (PAT). You can create one on GitHub by going to SettingsDeveloper settingsPersonal access tokens (Tokens classic).

You need these permissions:

  • write:packages
  • read:packages

(Quick tip: PATs have an expiration date. If yours expires, Kubernetes will eventually lose the ability to pull your images. I highly recommend setting a reminder in your calendar a few days before it expires so you don't get stuck with a broken deployment later).

Store it in an environment variable:

export GHCR_TOKEN=ghp_xxxxxxxxxxxxx

Then, log in to GHCR:

echo $GHCR_TOKEN | docker login ghcr.io -u <YOUR_GITHUB_USERNAME> --password-stdin

Now, tag and push your image. Replace <YOUR_GITHUB_USERNAME> with your actual username.

Note the --platform linux/arm64 flag: Raspberry Pi uses an ARM architecture, while most standard PCs and laptops use x86. These are different types of CPU architectures, and since an x86 image won't run on an ARM processor, this option is mandatory when building for the Pi.

docker build --platform linux/arm64 -t ghcr.io/<YOUR_GITHUB_USERNAME>/simple-app:latest .
docker push ghcr.io/<YOUR_GITHUB_USERNAME>/simple-app:latest

You should now see your image in the Packages tab of your GitHub profile.

Packages tab

Step 3 : How to pull your image from GitHub Container Registry

Now that the image is online, Kubernetes needs to pull it. To do this, we need to tell K3s which credentials to use for ghcr.io. On your Pi node, edit the registry configuration:

sudo nano /etc/rancher/k3s/registries.yaml

Add your credentials:

configs:
  "ghcr.io":
    auth:
      username: "<YOUR_GITHUB_USERNAME>"
      password: "<YOUR_GITHUB_PAT>"

Important: For K3s to pick up these changes, you'll need to restart the service:

sudo systemctl restart k3s

Step 4 : How to write a basic Kubernetes YAML configuration

We are now ready to deploy. To achieve this, we'll need to write some YAML to define our desired state. Let's break down the different blocks required for a basic service or website deployment.

1. The Namespace

The Namespace is the "box" that contains and organizes your applications. By defining kind: Namespace with the name website-namespace, we create a logical boundary. Every other resource in our file then uses metadata.namespace: website-namespace to tie itself to this box.

More generally, this and every other Kubernetes configuration share a few common fields: apiVersion (the version of the K8s API), kind (what we are creating), and metadata (the name of the resource and its namespace).

Here is how the namespace is defined:

apiVersion: v1
kind: Namespace
metadata:
  name: website-namespace

See Kubernetes Namespaces for more

2. The Deployment

The deployment defines the desired state of your application. Like every other resource, it has a name defined in its metadata. For larger apps, you might have multiple deployments in the same namespace, each managing a different part of your system.

Here is how the deployment is defined:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: website
  namespace: website-namespace
spec:
  replicas: 1
  selector:
    matchLabels:
      app: website-pod
  template:
    metadata:
      labels:
        app: website-pod
    spec:
      containers:
        - name: website-app
          image: ghcr.io/<YOUR_GITHUB_USERNAME>/simple-app:latest

Containers: This section defines the container to run inside the pod and which image to pull from GHCR. It has a name because a single pod can technically run multiple "tightly coupled" containers (e.g., your main app alongside a sidecar for logging or metrics).

Labels: We tag our pods using spec.template.metadata.labels (e.g., app: website-pod). Think of these as metadata tags to identify your resources.

Selectors: The deployment uses spec.selector to find which pods it's responsible for by matching these labels. In our simple case, the selector and the labels match exactly, which might look redundant, but this separation allows for very flexible targeting in larger setups.

Replicas: Finally, we specify how many pods (replicas of our container) we want to run (set to 1 here). See Kubernetes Deployments for more

3. The Service

Since pods are ephemeral and their IPs change, we need a stable point of contact to reach our application. The Service has its own unique name and acts as a router that takes in requests from the Ingress and sends them to the right pod. It also functions as a load balancer by distributing traffic across any pods that match its selector. In more complex setups, you'll often have multiple services handling different internal communications.

Here is how the service is defined:

apiVersion: v1
kind: Service
metadata:
  name: website-service
  namespace: website-namespace
spec:
  selector:
    app: website-pod
  ports:
    - port: 80
      targetPort: 80

Port Mapping: We map the external port (80) to the container's targetPort (80).

It might look weird to map port 80 to 80, but this translation is very useful in some situations. On Linux, binding to ports below 1024 (like 80 or 443) usually requires root privileges. By using a Service, we can accept public traffic on port 80 and forward it to a safe, non-privileged port (like 8080) inside the container if needed.

see Kubernetes Services for more

4. The Ingress

The Ingress is an HTTP router (a submodule under networking.k8s.io/v1) that defines which domain names lead to which services. Like the other resources, it has a specific name, and you can define several of them to manage different domains or entry points.

Here is how the ingress is defined:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: website-ingress
  namespace: website-namespace
spec:
  rules:
    - host: simple-website.home.arpa
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: website-service
                port:
                  number: 80

Rules: We define that the host simple-website.home.arpa leads to our website-service.

Paths: The / path with pathType: Prefix means anything under that domain will be routed to the same service. This includes simple-website.home.arpa/about or simple-website.home.arpa/blog/article-1.

See Kubernetes Ingress for more

Here is a diagram to show how these different components interact:

Kubernetes architecture

The full configuration

By concatenating all these blocks using the --- separator, we get our final configuration file. While you can keep them as separate files, combining them into a single config.yaml is often easier for small projects. As things get more complex, you might eventually want to split them back up.

Save the following content into a file named config.yaml (don't forget to update the image path with your username):

apiVersion: v1
kind: Namespace
metadata:
  name: website-namespace
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: website
  namespace: website-namespace
spec:
  replicas: 1
  selector:
    matchLabels:
      app: website-pod
  template:
    metadata:
      labels:
        app: website-pod
    spec:
      containers:
        - name: website-app
          image: ghcr.io/<YOUR_GITHUB_USERNAME>/simple-app:latest
---
apiVersion: v1
kind: Service
metadata:
  name: website-service
  namespace: website-namespace
spec:
  selector:
    app: website-pod
  ports:
    - port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: website-ingress
  namespace: website-namespace
spec:
  rules:
    - host: simple-website.home.arpa
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: website-service
                port:
                  number: 80

Step 5 : How to deploy your application to Kubernetes

Once your image is pushed and K3s is configured, apply the configuration:

kubectl apply -f config.yaml

You can check if everything is running correctly by listing the resources in our namespace. Since we used website-namespace, we need to specify it with the -n flag:

# Check all resources (pods, services, deployments)
kubectl get all -n website-namespace
 
# Specifically check the ingress
kubectl get ingress -n website-namespace

Wait a few seconds for the pod to be "Running". If you see ImagePullBackOff, double-check your registry credentials in Step 3!

If you followed the previous article on Pi-Hole and forwarded *.home.arpa domains to your Pi, your website should now be live at simple-website.home.arpa.

Web page

You can find the complete code here: github.com/Local-pie/simple-app

In the next article, we'll see how to deploy your website to the internet.

#kubernetes#docker#devops#k3s#github-container-registry
Judicael Poumay (Ph.D.)

Judicael Poumay (Ph.D.)

Follow me on LinkedIn for weekly content Judicaël Poumay

As an independent AI researcher/developer specialized in Natural Language Processing (NLP), I have a comprehensive expertise in the development and integration of AI systems, as well as data analysis.

Is your company looking to integrate AI solutions, analyze data, or strengthen its back-end development? Contact me!

Buy me a beer 🍺

Related Articles