This repository demonstrates how to deploy a full-stack Todo application with a Vue.js frontend, a FastAPI backend, and an SQLite database. The project is designed for learning purposes, with the goal of making Kubernetes deployment easy to understand. It includes examples of deployment using Kubernetes, both with and without Helm, as well as using Docker Compose.
I created this project because, while it is relatively easy to find resources for learning Kubernetes itself, I could not find a clear tutorial that shows how to deploy a full-stack application with custom docker images and persistent data. This repository aims to fill that gap and provide a practical example that learners can follow.
Before diving in, I assume you are already familiar with Docker and have basic overview of Kubernetes. This README is intensive but only explains much! This repository is not about explaining all the kubectl commands or how to debug, it is more about how to deploy your fullstack app using Kubernetes. In order to learn Kubernetes properly, checkout Techworld with Nana or Linkedin Learning courses.
I used Claude Sonnet 4.0 and Kiro to generate both UI and API projects as I wanted to focus on Kubernetes part of this project. You can find README files in both projects, however they were generated with Claude too. Once both projects were generated, I did the Kubernetes part myself.
- Architecture
- Deployment Options
- Prerequisites
- Deployment steps
- Kubernetes Configuration Overview
- Kubernetes deployment with Kind (Local)
- Helm Chart deployment
- AKS (Azure) deployment
- AKS cleanup
- Local Development Setup
- Frontend: Vue.js application (
simpei-ui/) - Backend: FastAPI application (
simpei/src/simpei/) - Database: SQLite (persistent volume in Kubernetes)
- Local Development: Docker Compose
- Local Kubernetes: Kind + Make commands
- Production Kubernetes: Helm chart with custom values
Please first make sure you have the following tools installed on your machine.
It is the quickest way to try this project. You only need to have Docker installed and make sure docker is running and simply run.
docker-compose up -dVisit http://localhost:3000 to access the Simpei app.
To stop the application, run
docker-compose downIt is fun time now. Before getting our hands dirty, let's first understand all the Kubernetes yaml files. The kubernetes/ directory contains essential YAML files for deploying the application. Each file serves a specific purpose in the overall architecture.
Creates a local Kubernetes cluster in Docker using Kind hence you do not need this file for Azure deployment. Let's see this block more closely
- role: control-plane
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCPThis YAML block defines the control-plane node and maps HTTP (80) and HTTPS (443) traffic from your local machine into the cluster, so you can access services (via ingress) just like you would in a real Kubernetes environment.
-
- role: control-plane
This defines a node in the Kind cluster.role: control-planemeans this node will act as the Kubernetes master node, running the API server, scheduler, controller-manager, etc.
-
extraPortMappings
This section tells Kind to map ports from the Docker container (where the control-plane node runs) to your host machine. Without this, ports exposed inside the Kubernetes node are not directly accessible from your local machine.Inside it:
containerPort: 80→ The port inside the Kind node container.hostPort: 80→ The port on your local machine that will forward traffic to the containerPort.protocol: TCP→ Protocol used (usuallyTCP, but could beUDPif specified).
Same for the second mapping.
By default, services in a Kind cluster are only reachable inside the cluster or through kubectl port-forward.
With extraPortMappings, you expose ports from the control-plane node to your host, which is useful for:
- Running an Ingress Controller (like NGINX).
- Ingress controllers typically listen on ports 80 (HTTP) and 443 (HTTPS).
- Mapping these to the host lets you access services via
localhostwithout extraport-forwarding.
This file contains both a Service and a Kubernetes Deployment for the FastAPI backend. The Deployment manages the application pods, while the Service provides network access to them.
Service Configuration:
Excerpt of service from the service.yaml file
spec:
ports:
- port: 8000 # Service listens on port 8000
targetPort: 8000 # Forwards traffic to container port 8000
selector:
app: server-depl # Routes traffic to pods with label `app: server-depl` (see deployment in service.yaml) (If it won't match, it won't work)
type: ClusterIP # ClusterIP service implies that this service is only accessible within clusterDeployment Configuration:
Excerpt of deployment from the service.yaml file
spec:
replicas: 1 # Runs exactly one instance of the backend pod
...
template:
spec:
securityContext: # Runs container as non-root user (UID/GID 1000) for security
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- image: simpei-api:latest # Container of fast api service
imagePullPolicy: IfNotPresent # Uses local image if available
name: simpei-api
ports:
- containerPort: 8000 # Container port 8000 exposed
volumeMounts: # Mounts persistent volume storage at `/app/data` for SQLite database
- name: data-volume
mountPath: app/data
volumes: # References the PVC (`server-depl-pvc`) for persistent data storage
- name: data-volume
persistentVolumeClaim:
claimName: server-depl-pvcThis file contains both a Kubernetes Deployment and a Service for the Vue.js frontend. The Deployment manages the UI pods, while the Service provides network access to them.
Service Configuration:
Nothing new to explain here.
Deployment Configuration:
Excerpt of deployment from the ui.yaml file
env:
- name: BACKEND_HOST
value: "server-serv.default.svc.cluster.local"
- name: BACKEND_PORT
value: "8000"env: Environment variables that configure backend connectionBACKEND_HOST: Uses Kubernetes DNS to find the backend serviceBACKEND_PORT: Backend service port (8000)
Creates a Persistent Volume Claim (PVC) that provides 1GB persistent storage for the SQLite database. This ensures data survives pod restarts and redeployments.
Complete PVC Configuration:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: server-depl-pvc
labels:
app: server-depl
annotations:
helm.sh/resource-policy: keep
spec:
accessModes:
- ReadWriteOnce
storageClassName: "standard"
resources:
requests:
storage: 1GiConfiguration Breakdown:
-
annotations:helm.sh/resource-policy: keep: Prevents Helm from deleting this PVC during upgrades or uninstalls, preserving your database data. Without this, your todos would be lost every time you redeploy!
-
accessModes:ReadWriteOnce: The volume can be mounted as read-write by a single node only. This is perfect for SQLite since it doesn't support concurrent writes from multiple processes.
-
storageClassName:"standard": Uses the default storage class provided by Kind (or your Kubernetes cluster). In Kind, this typically maps to local storage on the Docker container. In cloud environments, this might be SSD, HDD, or other storage types depending on your cluster configuration. We will use "managed" storage class provided by Azure (see in later section).
-
resources:requests.storage: 1Gi: Requests 1 gigabyte of storage space. This is the minimum amount guaranteed to be available. The actual storage allocated might be larger depending on the storage class configuration.
I can totally recommend this amazing Youtube video.
The Ingress resource acts as a reverse proxy and load balancer that routes external HTTP/HTTPS traffic to internal services within the Kubernetes cluster. Think of it as the "front door" to your application.
Complete Ingress Configuration:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: todo-ingress
spec:
ingressClassName: nginx
rules:
- host: todo.local
http:
paths:
- backend:
service:
name: ui-serv
port:
number: 3000
path: /
pathType: Prefix
status:
loadBalancer:
ingress:
- hostname: localhostConfiguration Breakdown:
-
ingressClassName: nginx: Specifies that this Ingress should be handled by the NGINX Ingress Controller. -
rules: Defines routing rules based on hostnames and pathshost: todo.local: Only requests with theHost: todo.localheader will match this rule. This is why we need to add127.0.0.1 todo.localto/etc/hosts(We will do it later) - it makes your browser send requests tolocalhostbut with the correct host header.
-
paths: Defines URL path-based routing within the hostpath: /: Matches all paths starting with/(essentially all requests to this host)pathType: Prefix: Means any path starting with/will match (e.g.,/,/todos,/api, etc.)backend: Specifies where to forward matching requestsservice.name: ui-serv: Routes to the frontend serviceservice.port.number: 3000: Uses port 3000 of the ui-serv service
-
status: Shows the current state of the Ingress resource (this section is typically populated by Kubernetes after deployment)loadBalancer.ingress: Lists the external endpoints where the Ingress is accessiblehostname: localhost: Indicates that the Ingress is accessible vialocalhost(in Kind's case, this reflects the local development setup)
After having understanding of yaml files it is time to do some real work. Run the following command to create the Kind cluster:
# Create Kubernetes local cluster
make clusterwait for a minute or two (sometimes cluster is not ready immediately)
# Load images into cluster
make load-imagesWe need to do one more change.
echo "127.0.0.1 todo.local" | sudo tee -a /etc/hostsNow we can deploy our application using Kubernetes.
Run the following command:
make deployAccess the application at http://todo.local
Note: Data will not persist when you run using
Kubectlcommands. To persist data, you need to useHelmdeployment.
Run
make undeployTo remove the application deployment.
First, make sure you have created the cluster and loaded images into it (see previous section). Now simply run:
helm install simpei ./chartTo uninstall the chart
helm uninstall simpeiBefore we start, know that deploying on AKS is not free. However, once you create your account you get 200$ credits for free for one month. These 200$ are more than enough to deploy and play around. Once you are done with your experiment, don't forget to delete all the resources (I will also write here how to do that).
Create your Azure account here.
az login # Enter 1 for subscriptionCreate Azure Resource group
az group create --name todo-app --location westusYou should get the following JSON back
{
"id": "/subscriptions/<some-id>/resourceGroups/todo-app",
"location": "westus",
"managedBy": null,
"name": "todo-app",
"properties": {
"provisioningState": "Succeeded"
},
"tags": null,
"type": "Microsoft.Resources/resourceGroups"
}Now let's create the AKS cluster (the following command will take a while)
az aks create --name todo-app-cluster --resource-group todo-app --generate-ssh-keys --node-vm-size Standard_B2s --node-count 2 --location westusIf everything works, then you should get a big JSON back!
Now let's login to the AKS cluster we just created!
az aks get-credentials --name todo-app-cluster --resource-group todo-appThe output should look like
Merged "todo-app-cluster" as current context in /Users/your_user/.kube/config
Remember from now on when you run
kubectlcommands, you will be executing them on AKS not on your local Kind cluster
Now we have to install nginx as the ingress controller, you can find the installation command here
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.13.1/deploy/static/provider/cloud/deploy.yamlNow in order to use our docker images by AKS cluster, we first need to create a Azure's container registry (ACR) and then push our images into it!
Let's create ACR now
az acr create --resource-group todo-app -n <acr_name, this name is globally unique> --sku Basic-n option is for your ACR name and it should be globally unique (so some name might already be taken)
Now we have to login to container registry we just created. In order to login, first we need to get credentials
az acr login -n <acr_name> --expose-token The above command should give you a JSON
{
"accessToken": "a huge string here",
"loginServer": "<your acr name>.azurecr.io",
"refreshToken": "a huge string here",
"username": "some zeros"
}Once we have the username and accessToken, then we are ready to login to ACR from our local Docker.
docker login <acr_name>.azurecr.io --username <username> --password <accessToken>
# output should be: Login SucceededNow as I am using Mac and we are going to deploy on Azure Linux images, so we need to build Docker images for Linux and then push
# Make sure you are at root project directory
docker buildx build --platform linux/amd64 -t <acr_name>.azurecr.io/simpei-ui:0.1.0 --push ./simpei-ui
docker buildx build --platform linux/amd64 -t <acr_name>.azurecr.io/simpei-api:0.1.0 --push ./simpeiNow we can check if our images were pushed successfully or not with the following command
az acr repository list --name <acr_name>
# [
# "simpei-api",
# "simpei-ui"
# ]Now as we have pushed our images to ACR, we need to connect ACR with Kubernetes so that Kubernetes can pull these images from ACR. For that we first need to create a pull secret
kubectl create secret docker-registry <name_of_pull_secret> --docker-password '<accessToken>' --docker-server=<acr_name> --docker-username=<username>
# Output: secret/<name_of_pull_secret> createdIn the above command, name_of_pull_secret can be anything and accessToken & username should be the same as we used when logging to ACR with docker.
Now we need to attach this pull secret with the AKS cluster's namespace. We do that by editing the service account as every namespace has one. Run the following command
kubectl edit sa default Running the above command will open up a file in the vim editor. You need to add the following section at the end of that.
imagePullSecrets:
- name: <name_of_pull_secret>then finally save the file. We are almost ready to see our web alive :-) Now we just need the external IP address of our nginx controller. Run
kubectl get svc -n ingress-nginx
# copy the external IP address of ingress-nginx-controllerThen edit the chart/values-azure.yaml file. Put your ACR name and external IP address there.
Now run
helm upgrade --install <name_of_deployment> ./chart -f chart/values-azure.yaml<name_of_deployment> can be any name (think of it as a tag). Wait for a few seconds, and finally visit http://external-ip.nip.io 🔥
You have done it.
Now to uninstall the deployment run:
helm uninstall <name_of_deployment>Very important step otherwise Azure will continue billing your app. First, let's delete the resource group you created:
az group delete --name <resource group name>Now delete the context from your local machine,
kubectl config get-contexts # get the name of the AKS cluster
kubectl config delete-context --name <name of your cluster>Now go to portal.azure.com/subscription, click on your subscription you created, and then cancel your subscription (make sure to turn off your subscription).
Fin, Tschüss
Prerequisites:
- Node.js (v14+)
- npm or yarn
cd simpei-ui
npm install
npm run serveDevelopment server: http://localhost:8080
Prerequisites:
- Python 3.8+
- uv (Python package manager)
cd simpei
uv sync
uv run uvicorn src.simpei.main:app --reloadAPI server: http://localhost:8000
API docs: http://localhost:8000/docs