Tech Blog by vClusterPress and Media Resources

Exploring Kubernetes on vCluster: Deploying a GitOps Stack

Sep 3, 2026
|
22
min Read
Exploring Kubernetes on vCluster: Deploying a GitOps Stack

When you are building applications on Kubernetes, GitOps has become a thing, or make that should be a thing, a big thing for that matter.

GitOps is a way to manage IT infrastructure and software using Git as the single source of truth. Instead of running manual scripts to update servers, you store all system configurations in code. Automated agents watch this code and update live environments to match.

The core principles

  • Declarative state: You describe the end goal (for example, "I need three running web servers") rather than the steps to get there.
  • Version control: All changes go through version-controlled pull requests (PRs) or merge requests (MRs). This allows teams to review and approve updates just like regular software code.
  • Automated synchronization: Specialized software agents monitor the Git repository for updates. When they see a change, they instantly apply it to your environment.
  • Continuous reconciliation: The agents do not just deploy once. They constantly check the live system against Git. If the live system drifts, the agent fixes it.

In our example below, we are a developer working locally on our laptop (although everything we are building below can be deployed exactly as defined onto a larger team shared deployment also), committing changes into the project GitHub repo.

We will be configuring a GitOps pipeline using GitHub Actions to "build" our Docker container, after which we will push the new container image into our hub.docker.com account.

IMPORTANT:

As part of this "build" process GitHub Actions also updates our k8s/deployment.yml file with the image tag assigned to the image when it was built and pushed into our container registry hosted in hub.docker.com. The update is done by replacing the "placeholder" variable next to the image: tag (line 21). This is an important concept to remember as we lay out the process and actions.

We will start by pre-configuring our Kubernetes cluster with Argo CD and define a "project", which we will in turn tell to monitor our GitHub project, specifically the above mentioned k8s/deployment.yml file.

Once it picks up that the file has been changed, Argo CD executes the steps as per our argocd-app.yml file, which deploys/redeploys our project as defined in k8s/deployment.yml onto our target Kubernetes cluster hosted on the vCluster stack.

Note: Argo CD together with GitHub Actions can also be configured to "push" the changes onto a target cluster. For myself, working in the financial industry (we don't trust easily), and as we are using GitHub Actions, which reside outside our network, the pull is a more secure pattern.

A "push" based pattern could very likely be preferred for applications deployed in say AWS, where the ECR (Elastic Container Registry) and EKS (Elastic Kubernetes Service) is all hosted in a secured account.

Diagram of the GitOps pipeline: a developer commits and pushes to GitHub, GitHub Actions builds and pushes the image to Docker Hub and updates deployment.yml, and Argo CD picks up the new target state and deploys it to Kubernetes running on vCluster

What do we have here, as far as the developer is concerned?

No cloud account required. No VMs. Just Docker and a few commands, and we have a fully functional Kubernetes development environment with an automated CI/CD deployment pipeline using Argo CD, GitHub and a Docker repository.

In our previous editions we built a complete monitoring stack for our environment, also commonly referred to as the metrics collection component of an observability stack (Part 1), but observability is the combination of metrics and logs, so in Part 2 we added log analytics based on Elasticsearch.

We are expanding our development environment in this blog, maturing our all-in-one local development environment so as to maximize developer productivity by providing them with an environment representing expected production as closely as possible.

All source code for this blog is available at georgelza/gitops-pipeline.

Why vCluster for this?

vCluster with the Docker driver gives you a multi-node Kubernetes cluster running entirely in Docker containers. For this project, that means a control plane and three worker nodes, enough capacity to run the full observability stack alongside demo workloads, all created with a single command:

vcluster create my-pipeline -f vcluster.yaml

The vcluster.yaml configures a 3-worker-node cluster:

controlPlane:
distro:
k8s:
version: "v1.36.0"
experimental:
docker:
nodes:
- name: "worker-1"
- name: "worker-2"
- name: "worker-3"

That's it. In under a minute, you have a fully functional Kubernetes cluster with multiple nodes, ready to host real workloads. When you are done for the day, vcluster pause my-pipeline frees up resources. vcluster resume my-pipeline picks up right where you left off.

What we will be deploying

Here's the full stack:

ComponentWhat it does
Argo CDA declarative, GitOps continuous delivery tool for Kubernetes.
GitHub repoA GitHub repository (commonly shortened to "repo") is essentially a digital project folder hosted on GitHub that stores your code, assets, and documentation.
GitHub runnerThe execution engine or compute instance that runs the jobs defined in your GitHub Actions automation and CI/CD pipelines. Think of it as a virtual or physical server that wakes up when triggered, clones your code, installs your required packages, and runs tasks like automated testing or deployment.
Container repoA Docker repository is a collection of related Docker container images organized by "tags" that represent different versions of the same application. It works like a GitHub repository, but instead of hosting raw source code, it stores pre-packaged, ready-to-run software builds.

Demo applications

We will be using a very simple FastAPI web application based on Python (app.py).

import os
from fastapi import FastAPI

app = FastAPI(title="K8s-GitOps-Demo")

@app.get("/")
def read_root():
return {
"status": "healthy",
"engine": "ArgoCD Pull Model",
"message": "Hello from a secure GitOps workflow!"
}

@app.get("/healthz")
def health_check():
return {"status": "OK"}

Deploying the stack

The deployment follows a specific order since components depend on each other:

  • Create our Kubernetes cluster.
  • Create our app and argocd namespaces.
  • Deploy the Argo CD stack onto the Kubernetes cluster.
  • Create a Docker project and configure a Docker personal access token (read/write).
  • Create a GitHub project and configure the Docker access secrets.
  • Upload/push our project from the local project folder to the GitHub repository.
  • Resulting in an automated deployment by Argo CD on our Kubernetes environment.

For the complete step-by-step walkthrough, see README.md and BUILD.md.

The bigger picture

This project is part of a series building up a complete local Kubernetes development environment:

By the end of the series, you will have a local environment with application hosting, ingress routing, metrics collection, dashboarding, alerting, long-term metric storage, and log analytics, configured with an automated CI/CD pipeline rooted in GitHub Actions.

That's a genuinely useful development platform, and it all runs on your laptop.

Deployment

The deployment has been divided into 2 sections. First, the core deployment, which is our Kubernetes cluster (including Argo CD), and secondly our GitOps pipeline rooted in GitHub, provided with the various required YAML files as part of our project repo.

So let's get started.

Core deployment

(Note: you can skip the core deployment if you still have it running from the previous blog. The GitOps stack fits nicely on what we previously built.)

git clone https://github.com/georgelza/gitops-pipeline.git
cd gitops-pipeline

First, a screenshot showing what we are starting with. The steps and screenshots below are basically our output from the steps as per BUILD.md.

docker ps

Terminal output of docker ps before the cluster is created, showing no vCluster containers running

sudo vcluster create my-pipeline --values vcluster.yaml

vcluster.yaml

controlPlane:
# Configure the backing store for vCluster's data
backingStore: # Enterprise feature, requires a license
etcd:
embedded:
enabled: true # Run etcd inside the vCluster pod
experimental:
docker:
nodes:
- name: worker-1
volumes:
- "./data/vc:/data"
env:
- "NODE_ROLE=worker"
- name: worker-2
volumes:
- "./data/vc:/data"
env:
- "NODE_ROLE=worker"
- name: worker-3
volumes:
- "./data/vc:/data"
env:
- "NODE_ROLE=worker"

Terminal output of vcluster create my-pipeline, showing the tenant cluster being created and the kube context switched

Now a docker ps after we have created our cluster.

docker ps

Terminal output of docker ps after cluster creation, listing the vCluster control plane and the three worker node containers

kubectl get nodes

Terminal output of kubectl get nodes listing the control plane and worker-1, worker-2 and worker-3

Let's label our nodes correctly.

kubectl label node worker-1 worker-2 worker-3 node-role.kubernetes.io/worker=worker

Terminal output confirming the worker role label was applied to worker-1, worker-2 and worker-3

kubectl get nodes

Terminal output of kubectl get nodes after labeling, with the three workers now showing the worker role

kubectl get namespaces

Our base/default namespaces:

Terminal output of kubectl get namespaces showing the default Kubernetes namespaces

Let's quickly create our 2 namespaces that will be utilised.

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
name: app
EOF

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
name: argocd
EOF

Terminal output showing the app and argocd namespaces being created

GitOps pipeline deployment

Next is our GitOps pipeline, which we will execute by following the steps as per GITOPS.md. The steps below are as per step 3 in the GITOPS.md document.

Phase 1

First we will deploy Argo CD.

# 1. Deploy stable ArgoCD infrastructure components
kubectl -n argocd create -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

Terminal output listing every Argo CD resource created by the install manifest

This will put kubectl into watch mode. Hit Ctrl-C once everything is running.

# 2. Block and watch until all operators show "Running"
kubectl get pods -n argocd -w

Terminal output of kubectl get pods -n argocd with all Argo CD pods in the Running state

Phase 2

First we need to create a project in hub.docker.com. Once we have a project we create an access token, also referred to as a personal access token.

Phase 3

Once we have the above container project created and the access key (personal access token) created, we configure GitHub with Secrets and variables / Actions.

This grants GitHub the required read/write access to create a new container in the Docker project.

Think of GitHub as the builder that needs keys to enter your warehouse (Docker Hub). If you paste these keys directly into your code, anyone who looks at your repository can steal them. Instead, you put them into GitHub's secure vault called Actions secrets.

Here is exactly what you do, step by step, inside your web browser.

Step 0: Register/create a new repository. For our blog we're using gitops-pipeline.

Step 1: Open your repository's vault

  • Open your web browser and go to GitHub.
  • Navigate to your specific code repository for this project.
  • Look at the horizontal menu tabs near the top (Code, Issues, Pull Requests...). Click on the Settings tab (it has a gear icon).

Step 2: Navigate to Actions secrets

  • On the left-hand sidebar, scroll down until you see Security and quality.
  • Click on Secrets and variables to expand it, then click on Actions.

You are now in the secure vault workspace.

Step 3: Add your Docker Hub username

  • Click the green button at the right/middle that says New repository secret.
  • In the Name field, type exactly this: DOCKERHUB_USERNAME
  • In the Secret field, type your actual Docker Hub username (the name you use to log into hub.docker.com).
  • Click the green Add secret button.

Step 4: Add your Docker Hub key (the token)

  • Click that green New repository secret button one more time.
  • In the Name field, type exactly this: DOCKERHUB_TOKEN
  • In the Secret field, paste the complete personal access token string you copied from Docker Hub.
  • Click the green Add secret button.

What did we just accomplish?

By doing this, you have securely wired the two platforms together without hardcoding sensitive details.

When you now push your code, the GitHub-hosted runner spins up and runs the automated blueprint file (.github/workflows/ci.yml). When it reaches these lines:

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

The runner automatically reaches into the secure vault, grabs your username and token on the fly, logs into Docker Hub, pushes your compiled Python container, and vanishes, keeping your credentials entirely secure.

.github/workflows/ci.yml

name: CI Build and Manifest Update

on:
push:
branches:
- main
paths-ignore:
- 'deployment.yml' # <-- Updated from 'k8s/**'

jobs:
build-and-patch:
runs-on: ubuntu-latest
permissions:
contents: write # Allows pushing the updated YAML tag back to Git

steps:
- name: Checkout Source Code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetches full history for proper Git tracking

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: true
# Instructs Buildx to compile for both standard x86 and ARM servers
platforms: linux/amd64,linux/arm64
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/python-fastapi-app:latest
${{ secrets.DOCKERHUB_USERNAME }}/python-fastapi-app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Update Manifest Image Tag
run: |
# 1. Target the file inside your k8s/ folder matching your exact .yml extension
sed -i -E "s|(image: ${{ secrets.DOCKERHUB_USERNAME }}/python-fastapi-app:).*|\1${{ github.sha }}|" k8s/deployment.yml

# 2. Configure a Git Identity
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

# 3. Track and commit the exact file from the k8s directory path
git add k8s/deployment.yml
git commit -m "chore: auto-update deployment image tag to ${{ github.sha }} [skip ci]"
git push

Now that these secrets are saved, you are ready to commit and push your files from your local workstation to trigger the initial container build.

GitHub Actions secrets and variables page showing the DOCKERHUB_TOKEN and DOCKERHUB_USERNAME repository secrets

At this point we have completed all the prerequisites.

Phase 4

Provision the runner and push the baseline (from the workstation).

Now we are ready to push our local project/code to GitHub. This triggers the GitHub-hosted runner to provision its computing workspace, build the image, and output the tracking tag.

git add .
git commit -m "ci: establish core framework baseline"
git push origin main

Pause strategy: open your GitHub repository web browser interface and click on the Actions tab. Wait until the active runner job executes completely and displays a green checkmark. If you inspect your k8s/deployment.yml file in the GitHub UI, you will see that the text :placeholder has been updated to your long Git commit SHA string.

Phase 5

Wire the cluster to pull manifests (on the cluster control plane).

With the correct image tag committed back to your Git repository, we will now instruct Argo CD to monitor the repository and deploy the resources to our targeted namespace. You will notice we reference our project spec:source:repoURL. Also take note of the path tag.

Run this command on your cluster terminal:

kubectl apply -f argocd-app.yml

argocd-app.yml

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: python-gitops-pipeline
namespace: argocd # Directs the declaration to the ArgoCD controller space
spec:
project: default
source:
repoURL: 'https://github.com/georgelza/gitops-pipeline.git'
targetRevision: main
path: k8s # Instructs ArgoCD to track your manifests inside the k8s folder
destination:
server: 'https://kubernetes.default.svc' # Instructs it to deploy to the local cluster
namespace: app # Directs deployment straight into your isolated 'app' namespace
syncPolicy:
automated:
prune: true # Automatically deletes K8s resources if you remove them from Git
selfHeal: true # Overwrites manual cluster overrides to keep Git as the single source of truth

Terminal output confirming the python-gitops-pipeline Argo CD application was created

Argo CD instantly reads the k8s/deployment.yml file from your GitHub repository, translates the requirements, handles the target definitions, and sets up our application inside the app namespace.

apiVersion: apps/v1
kind: Deployment
metadata:
name: python-app-deployment
namespace: app
labels:
app: python-fastapi
spec:
replicas: 2
selector:
matchLabels:
app: python-fastapi
template:
metadata:
labels:
app: python-fastapi
spec:
containers:
- name: fastapi-app
image: georgelza/python-fastapi-app:9d78760ac56e42d773290eee71769ed4a40c3d70
imagePullPolicy: Always
ports:
- containerPort: 8000
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: python-app-service
namespace: app
spec:
selector:
app: python-fastapi
ports:
- protocol: TCP
port: 80
targetPort: 8000
type: NodePort

Verification

Let's now verify that our application has been safely pulled and deployed within its designated workspace/namespace:

kubectl get deployments,services,pods -n app -o wide

Output:

Terminal output showing the python-app deployment, the NodePort service and two running pods in the app namespace

Future redeploy action-trigger loop

For every future code alteration or deployment update, we do not need to repeat any of the steps above. The automated loops trigger natively when a commit lands. You push an update to app.py from your local workspace:

git add app.py
git commit -m "feat: upgrade runtime message block"
git push origin main

Access our application

To access our newly deployed Python FastAPI application from our workstation, we have two options based on our NodePort configuration.

Option 1: access via NodePort (direct cluster routing)

Our python-app-service manifest correctly configured a NodePort service. Looking at our kubectl get output:

service/python-app-service NodePort 10.99.36.33 <none> 80:30449/TCP

Kubernetes has opened port 30449 across every single physical node in our cluster.

To access the app directly, find the host IP address of any of the cluster nodes (such as the machine hosting worker-2 or worker-3) and navigate to it in the browser of choice on the high port (to the left of /TCP at the end of the line).

http://<ANY_CLUSTER_NODE_IP>:30449/

Option 2: use a kubectl port-forward tunnel (recommended for testing)

If we want to map the cluster application specifically to our workstation's localhost interface for convenient local testing, we can instruct kubectl to open a secure, bidirectional network tunnel from our desktop straight to the service.

Run this command in a separate terminal window on our workstation:

kubectl port-forward svc/python-app-service -n app 8080:80

What this does:

This binds our workstation's local port 8080 directly to the cluster service's incoming port 80.

Now, you may ask, but our app is exposed on port 8000 in the container, see the Dockerfile, should we not be using this port?

Our configuration is actually completely correct as it stands, and we do not need to change the application's internal container port (8000).

In our Kubernetes manifest, we created a translation layer using the Service definition. Let's look at how the traffic flows:

ports:
- protocol: TCP
port: 80 # <-- The Service's entry port inside the cluster
targetPort: 8000 # <-- The port the Service forwards to inside the container

Because of this mapping, the Service listens on port 80 inside the cluster network and automatically shifts the traffic to port 8000 inside our Python container.

Why option 2 (kubectl port-forward) works with our setup:

When we run a port-forward command, we specify LOCAL_PORT:CLUSTER_SERVICE_PORT. Since our service is listening on port 80, our command should target port 80 like this:

kubectl port-forward svc/python-app-service -n app 8080:80

Terminal output of kubectl port-forward showing traffic forwarded from local port 8080 to the service on port 80

In a new terminal, execute the below.

curl -i http://localhost:8080/

Terminal output of curl against localhost:8080 returning the healthy JSON response from the FastAPI app

We can now navigate with our browser to any of the below addresses:

What we built:

  • GitHub runner compiles: the GitHub Actions workflow detects the code changes, builds a fresh image tagged with the new Git commit SHA, and pushes it to Docker Hub.
  • GitHub runner patches Git: the runner overwrites the image tag (our placeholder value mentioned previously) inside k8s/deployment.yml with the new Git SHA and commits it back to the repository.
  • Argo CD deploys: it detects the updated manifest in Git, pulls down the new declaration, and coordinates a safe, zero-downtime rolling deployment upgrade within your cluster's app namespace.

Scope creep, whenever does this not happen

And like all projects, things change. You delivered an amazing piece of work and the client loved it, so the boss surprises you with additional requirements. We will just call it app2 (another simple little Python/FastAPI app).

Below are the artefacts that need to be modified/replaced, see \<project_root\>/app2.

Note: to be able to roll back, if required, I have placed backup copies of all the original files which we will modify as part of the app2 deployment in a backup folder.

New artifacts

app2.py

from fastapi import FastAPI

app = FastAPI(title="K8s-GitOps-App2-Helper")

@app.get("/")
def read_root():
return {
"status": "healthy",
"component": "App 2 Helper Sub-System",
"message": "Hello! I am running on port 9000 in my own namespace."
}

@app.get("/healthz")
def health_check():
return {"status": "OK"}

Dockerfile

FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn
COPY app2.py .
EXPOSE 9000
USER 65534
CMD ["uvicorn", "app2:app", "--host", "0.0.0.0", "--port", "9000"]

Step 1: To deploy we will copy/replace the current file k8s/deployment.yml with the below (see the new app2 section three quarters of the way down).

new deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
name: python-app-deployment
namespace: app
labels:
app: python-fastapi
spec:
replicas: 2
selector:
matchLabels:
app: python-fastapi
template:
metadata:
labels:
app: python-fastapi
spec:
containers:
- name: fastapi-app
image: georgelza/python-fastapi-app:c7770605464afad40e9d4fa8736768736d8beb90
imagePullPolicy: Always
ports:
- containerPort: 8000
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: python-app-service
namespace: app
spec:
selector:
app: python-fastapi
ports:
- protocol: TCP
port: 80
targetPort: 8000
type: NodePort
---
apiVersion: v1
kind: Namespace
metadata:
name: app2-space
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: python-app2-deployment
namespace: app2-space
labels:
app: python-fastapi-two
spec:
replicas: 1
selector:
matchLabels:
app: python-fastapi-two
template:
metadata:
labels:
app: python-fastapi-two
spec:
containers:
- name: fastapi-app2
# The runner will dynamically patch this placeholder just like App 1
image: georgelza/python-fastapi-app2:placeholder
imagePullPolicy: Always
ports:
- containerPort: 9000
livenessProbe:
httpGet:
path: /healthz
port: 9000
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /healthz
port: 9000
initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: python-app2-service
namespace: app2-space
spec:
selector:
app: python-fastapi-two
ports:
- protocol: TCP
port: 80
targetPort: 9000
type: NodePort

Step 2: To deploy we will copy/replace the current file .github/workflows/ci.yml with the below (see the new Build App 2 section three quarters of the way down).

new .github/workflows/ci.yml

name: CI Build and Manifest Update

on:
push:
branches:
- main
paths-ignore:
- 'k8s/**'

jobs:
build-and-patch:
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Checkout Source Code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

# BUILD APP 1 (Untouched)
- name: Build and Push App 1
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/python-fastapi-app:latest
${{ secrets.DOCKERHUB_USERNAME }}/python-fastapi-app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

# BUILD APP 2 (Addition)
- name: Build and Push App 2
uses: docker/build-push-action@v5
with:
context: ./app2
file: ./app2/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/python-fastapi-app2:latest
${{ secrets.DOCKERHUB_USERNAME }}/python-fastapi-app2:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

# PATCH BOTH MANIFEST TAGS
- name: Update Manifest Image Tags
run: |
# Patches both app images inside the same k8s/deployment.yml file
sed -i -E "s|(image: ${{ secrets.DOCKERHUB_USERNAME }}/python-fastapi-app:).*|\1${{ github.sha }}|" k8s/deployment.yml
sed -i -E "s|(image: ${{ secrets.DOCKERHUB_USERNAME }}/python-fastapi-app2:).*|\1${{ github.sha }}|" k8s/deployment.yml

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

git add k8s/deployment.yml
git commit -m "chore: auto-update image tags for app1 and app2 to ${{ github.sha }} [skip ci]"
git push

Step 3: execution (the Git loop)

Since Argo CD is already tracking your k8s/ folder via our existing application setup, we don't even need to register a new file with Argo CD. The moment we commit these additions, Argo CD will notice the appended blocks at the bottom of the manifest and natively deploy them.

Execute the following commands locally after copying the new artefacts as per above:

# 1. Bring down the tracking tag changes from the last run
git pull origin main

# 2. Stage the new app2 folder and file updates
git add .

# 3. Commit the structural expansion non-invasively
git commit -m "feat: non-invasively introduce app2 helper sub-system"

# 4. Push to production
git push origin main

Once the pipeline goes green, we can verify that both components are happily coexisting in our cluster under their own namespaces.

New deployment.yml as updated by the GitHub Actions process. Take note that both :placeholder values on line 21 and 75 have been updated.

apiVersion: apps/v1
kind: Deployment
metadata:
name: python-app-deployment
namespace: app
labels:
app: python-fastapi
spec:
replicas: 2
selector:
matchLabels:
app: python-fastapi
template:
metadata:
labels:
app: python-fastapi
spec:
containers:
- name: fastapi-app
image: georgelza/python-fastapi-app:8847ab9c4a325412716080608e21e9745a29d759
imagePullPolicy: Always
ports:
- containerPort: 8000
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: python-app-service
namespace: app
spec:
selector:
app: python-fastapi
ports:
- protocol: TCP
port: 80
targetPort: 8000
type: NodePort
---
apiVersion: v1
kind: Namespace
metadata:
name: app2-space
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: python-app2-deployment
namespace: app2-space
labels:
app: python-fastapi-two
spec:
replicas: 1
selector:
matchLabels:
app: python-fastapi-two
template:
metadata:
labels:
app: python-fastapi-two
spec:
containers:
- name: fastapi-app2
# The runner will dynamically patch this placeholder just like App 1
image: georgelza/python-fastapi-app2:8847ab9c4a325412716080608e21e9745a29d759
imagePullPolicy: Always
ports:
- containerPort: 9000
livenessProbe:
httpGet:
path: /healthz
port: 9000
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /healthz
port: 9000
initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: python-app2-service
namespace: app2-space
spec:
selector:
app: python-fastapi-two
ports:
- protocol: TCP
port: 80
targetPort: 9000
type: NodePort

NOTE: Our hub.docker.com personal access token allows GitHub Actions to create the new repository required for app2 which will contain the new image. We will also notice that both app and app2 tags are the same. This is due to the fact that the GitHub Actions runner compiled and built both images during the same execution process.

kubectl get pods,services,deployments -n app
kubectl get pods,services,deployments -n app2-space

Terminal output listing the pods, services and deployments in the app namespace after app2 was introduced

Terminal output listing the pods, services and deployments in the app2-space namespace

Step 4: verify

We can now configure a new port-forward as per the previous section to reach our application.

kubectl port-forward svc/python-app2-service -n app2-space 9080:80

In a new terminal:

curl -i http://localhost:9080/

Terminal output of curl against localhost:9080 returning the App 2 helper sub-system JSON response

We can now also navigate using our browser to:

Access the Argo CD management console

Argo CD itself comes with a fully functional console/UI. To access it, first note that the default username is admin.

Argo CD creates a random password during installation. This can be retrieved using the below command.

kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

Terminal output showing the decoded Argo CD initial admin password

Now, to access the console we first need to configure another port-forward (note we're using 8081 for this, as we previously already utilised 8080):

kubectl port-forward svc/argocd-server -n argocd 8081:443

Terminal output of kubectl port-forward for the argocd-server service on local port 8081

Argo CD web console showing the python-gitops-pipeline application as Healthy and Synced, tracking the k8s path on the main branch

NOTE: Because we're working on our project locally and GitHub Actions is also modifying our k8s/deployment.yml, you will be getting the below error stack:

git add .
git commit -m "<comment>"
git push

Results in:

To https://github.com/georgelza/gitops-pipeline.git
! [rejected] main -> main (fetch first)
error: failed to push some refs to 'https://github.com/georgelza/gitops-pipeline.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

Quick fix:

# git add <specify specific file>
git add GITOPS_README.md
git commit -m "<comment>"
git pull origin main
git config pull.rebase false
git push origin main

Summary

Well, I could try and write a long summary here, but that was it, really very simple. All enabled on our local desktop/laptop by running a Kubernetes cluster inside vCluster. The above and previous blogs provide us with a complete framework, allowing for a real world development environment, with minimal to no changes when we deploy to a production Kubernetes cluster.

Share:
Get started with the #1 tenant isolation platform.

Give your tenants the hyperscaler experience, ready in seconds.

Ready to take vCluster for a spin?

Deploy your first virtual cluster today.