Most GitOps tutorials assume a managed cloud Kubernetes cluster with built-in load balancers, IAM-based container registry access, and ready-made secret management. In a real self-hosted GitOps pipeline setup, you get none of that for free.
This guide covers everything, including deploying a bare-metal Kubernetes cluster with ArgoCD, MetalLB, NGINX Ingress, app-of-apps GitOps, encrypted secrets, and secure WireGuard connectivity. All tuned for a production-ready self-hosted GitOps pipeline setup.
Why Use GitOps on Bare Metal?
A self-hosted GitOps pipeline setup gives you full control over your servers, your budget, and where your data lives. Instead of relying on a cloud provider’s control plane, you run Kubernetes clusters on dedicated machines you own or rent from a provider you trust.
On bare metal, you store all infrastructure, add-ons, and application configs in Git, and ArgoCD keeps your cluster in sync with that repository. This continuous sync is the core idea behind any self-hosted GitOps pipeline setup.
If you need reliable hardware for Kubernetes and GitOps, you can run this self-hosted GitOps pipeline setup on high-performance dedicated servers built for self-managed DevOps workflows.
Prerequisites for Self-Hosted GitOps Pipeline Setup
Before you start, make sure to have the prerequisites ready for a self-hosted GitOps pipeline setup:
- One or more dedicated servers running Ubuntu 22.04 or newer.
- Kubernetes cluster, kubeadm or k3s, up and running, with
kubectl configured.
- Static IPs and a routable LAN segment for MetalLB.
- Git repository, GitHub, GitLab, Gitea, etc., for your GitOps manifests.
Note: For GPU workloads inside the same self-hosted GitOps pipeline setup, you can consider dedicated GPU servers optimized for Kubernetes and AI/ML hosting.
Step 1. Create a Bare-Metal Kubernetes Cluster
If your cluster isn’t ready yet, you can follow the steps below to set up Kubernetes on bare-metal for a reliable self-hosted GitOps pipeline setup.
First, make sure the node has the right kernel networking settings and swap disabled so kubeadm’s preflight checks pass:
sudo swapoff -a sudo sed -i '/ swap / s/^/#/' /etc/fstab
Then enable bridge netfilter and IP forwarding, which Kubernetes needs to route pod traffic:
sudo modprobe br_netfilter sudo tee /etc/sysctl.d/kubernetes.conf <<EOFnet.bridge.bridge-nf-call-ip6tables = 1net.bridge.bridge-nf-call-iptables = 1net.ipv4.ip_forward = 1EOF sudo sysctl --system
Install Kubernetes components:
sudo mkdir -p /etc/apt/keyrings curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.29/deb/Release.key \ | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \https://pkgs.k8s.io/core:/stable:/v1.29/deb/ /" \| sudo tee /etc/apt/sources.list.d/kubernetes.list sudo apt updatesudo apt install kubeadm kubelet kubectl -ysudo systemctl enable kubelet
Then, use the command below to initialize the Kubernetes control plane and set the IP range for pod networking in the cluster:
sudo kubeadm init --pod-network-cidr=10.244.0.0/16
Configure kubectl for your user:
mkdir -p $HOME/.kubesudo cp /etc/kubernetes/admin.conf $HOME/.kube/configsudo chown $(id -u):$(id -g) $HOME/.kube/config
You must install a CNI plugin such as Flannel by applying the kube-flannel.yml manifest, which sets up pod networking across all nodes in your cluster:
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml
Finally, you must run the command below on each worker node so it can connect to the control plane and become part of the Kubernetes cluster:
Once nodes are ready, you have a solid base for your self-hosted GitOps pipeline setup.
Step 2. Install MetalLB for Bare-Metal Load Balancing
MetalLB is what makes LoadBalancer services work on bare-metal, which is a critical component of a self-hosted GitOps pipeline setup.
You can install the native manifests with:
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.16.1/config/manifests/metallb-native.yaml kubectl wait --for=condition=Ready pod --all \ -n metallb-system --timeout=180s
Then, configure the IP pool and L2 advertisement. Pick a free IP range from your LAN for this self-hosted GitOps pipeline setup:
cat <<EOF | kubectl apply -f -apiVersion: metallb.io/v1beta1kind: IPAddressPoolmetadata: name: baremetal-pool namespace: metallb-systemspec: addresses: - 192.168.1.200-192.168.1.240---apiVersion: metallb.io/v1beta1kind: L2Advertisementmetadata: name: baremetal-l2 namespace: metallb-systemspec: ipAddressPools: - baremetal-poolEOF
Now, any LoadBalancer service in your self-hosted GitOps pipeline setup will receive a real IP from this pool.
For a deeper MetalLB bare-metal load balancing setup, check the full MetalLB guide for bare-metal Kubernetes.
Step 3. Install NGINX Ingress Controller with LoadBalancer Service
NGINX Ingress is the component that routes external HTTP/HTTPS traffic into services running inside your cluster. In a self-hosted GitOps pipeline setup on bare metal, you don’t get a cloud ingress or load balancer by default, so you can install NGINX Ingress yourself and expose it via a LoadBalancer service backed by MetalLB.
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginxhelm repo update helm install ingress-nginx ingress-nginx/ingress-nginx \ --namespace ingress-nginx \ --create-namespace \ --set controller.service.type=LoadBalancer
kubectl get svc -n ingress-nginx
You should see an EXTERNAL-IP assigned by MetalLB, which becomes the main entrypoint for HTTP/HTTPS traffic.
Step 4. Install ArgoCD on Kubernetes
ArgoCD is the GitOps engine that keeps your cluster in sync with what’s defined in Git. In this step, you’ll install the latest stable ArgoCD release on your Kubernetes cluster so your self-hosted GitOps pipeline setup can continuously apply and reconcile manifests from your Git repository.
Once ArgoCD is running, every change you push to Git can be safely rolled out to your bare-metal cluster with full visibility and automated rollback.
Create namespace and apply stable install manifest:
kubectl create namespace argocd kubectl apply -n argocd --server-side --force-conflicts \ -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
Wait for deployments/pods to be available:
kubectl wait --for=condition=available --timeout=300s \ deployment/argocd-server -n argocd kubectl get pods -n argocd
This gives you a current ArgoCD release suitable for a robust self-hosted GitOps pipeline setup. For HA, you can use ha/install.yaml in the same repository.
By default, argocd-server is ClusterIP. For initial configuration, use port-forwarding:
kubectl port-forward svc/argocd-server -n argocd 8080:443
Open https://localhost:8080 in your browser. Get the admin password:
kubectl -n argocd get secret argocd-initial-admin-secret \ -o jsonpath="{.data.password}" | base64 -d && echo
Username is admin. Log in, and you’re inside the control plane of your self-hosted GitOps pipeline setup.
For more detailed information about ArgoCD installation, check the official ArgoCD Docs.
Step 5. Expose ArgoCD via Ingress
For long-term operations, you must expose ArgoCD via NGINX Ingress rather than NodePort.
Here is an example argocd-ingress.yaml file:
1apiVersion: networking.k8s.io/v12kind: Ingress3metadata:4 name: argocd-server-ingress5 namespace: argocd6 annotations:7 nginx.ingress.kubernetes.io/ssl-redirect: "true"8 nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"9spec:10 tls:11 - hosts:12 - argocd.example.com13 secretName: argocd-tls14 rules:15 - host: argocd.example.com16 http:17 paths:18 - path: /19 pathType: Prefix20 backend:21 service:22 name: argocd-server23 port:24 number: 443
kubectl apply -f argocd-ingress.yaml
Point argocd.example.com to the MetalLB IP of ingress-nginx. Now your self-hosted GitOps pipeline setup serves ArgoCD securely over HTTPS.
Step 6. Install the ArgoCD CLI
For fast operations in a self-hosted GitOps pipeline setup, you can install the ArgoCD CLI from the latest release:
curl -sSL -o argocd-linux-amd64 \ https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64 sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocdrm argocd-linux-amd64 argocd version --short --client
Use argocd login with your ingress or port-forward endpoint to manage applications:
argocd login argocd.example.com --username admin --password YOUR_PASSWORD --grpc-web
Structure Git for a Multi-App ArgoCD Setup
The app-of-apps pattern is a way to structure your Git repository so one ArgoCD Application can manage many other Applications. This makes your self-hosted GitOps pipeline setup easier to set up, easier to grow, and much simpler to keep consistent across environments.
Suggested layout includes:
gitops/ clusters/ prod/ root-app.yaml apps/ metallb-app.yaml ingress-app.yaml infra-storage-app.yaml workloads-app.yaml apps/ metallb/ values.yaml kustomization.yaml ingress-nginx/ values.yaml kustomization.yaml my-app/ deployment.yaml service.yaml ingress.yaml infra/ storageclass/ local-path-sc.yaml secrets/ sops/ registry-credentials.yaml
This structure makes Git the single place where you define everything in your self-hosted GitOps pipeline setup, such as your core infrastructure, extra add-ons, and all application workloads.
Root app-of-apps Application:
This is the entry point for your GitOps setup. You define it in clusters/prod/root-app.yaml inside your Git repo, and it tells ArgoCD to manage everything under clusters/prod/ as a group of child applications. When you sync this root app, ArgoCD automatically creates and updates all the other apps and infrastructure pieces in your self-hosted GitOps pipeline setup.
1apiVersion: argoproj.io/v1alpha12kind: Application3metadata:4 name: root-app5 namespace: argocd6spec:7 project: default8 source:9 repoURL: https://github.com/your-org/gitops.git10 targetRevision: main11 path: clusters/prod12 destination:13 server: https://kubernetes.default.svc14 namespace: argocd15 syncPolicy:16 automated:17 prune: true18 selfHeal: true
Private Container Registries Without Cloud IAM
In a self-hosted GitOps pipeline setup, image pulls often hit a self-hosted registry without IAM integration. You must declare registry credentials explicitly.
Create the secret:
kubectl create secret docker-registry regcred \ --docker-server=registry.example.com \ --docker-username=your_user \ --docker-password=your_password \ --docker-email=[email protected] \ -n your-app-namespace
Reference it in your workload manifest stored in Git:
1apiVersion: apps/v12kind: Deployment3metadata:4 name: my-app5 namespace: your-app-namespace6spec:7 replicas: 28 selector:9 matchLabels:10 app: my-app11 template:12 metadata:13 labels:14 app: my-app15 spec:16 imagePullSecrets:17 - name: regcred18 containers:19 - name: my-app20 image: registry.example.com/your-org/my-app:latest21 ports:22 - containerPort: 8080
ArgoCD syncs this, and your pods can pull private images.
Secrets Management with SOPS
Because your self-hosted GitOps pipeline setup doesn’t necessarily include a managed secret store, SOPS lets you safely keep secrets in Git.
- Install SOPS locally and pick a key backend, age, or GPG.
- Write Kubernetes
Secret manifests with plaintext values.
- Run
sops -e secret.yaml > secret.enc.yaml and commit the encrypted file.
- Deploy a SOPS-aware controller, such as
ksops, sops-operator, or a Helm plugin, in your cluster.
When ArgoCD syncs, it applies the encrypted secret manifests, the SOPS controller decrypts them, and the workloads get their secrets, all while your self-hosted GitOps pipeline setup stays fully defined in Git and easy to review.
Storage Classes and Persistent Volumes for Bare Metal
On bare-metal Kubernetes, you don’t get cloud storage classes or managed disks. To run databases and other stateful apps in a self-hosted GitOps pipeline setup, you must define your own StorageClasses and PersistentVolumes so the cluster knows where and how to create data volumes.
For single-node or simple lab setups:
1apiVersion: storage.k8s.io/v12kind: StorageClass3metadata:4 name: local-path5provisioner: rancher.io/local-path6volumeBindingMode: WaitForFirstConsumer
Save this YAML as infra/storageclass/local-path-sc.yaml in your Git repo, and let ArgoCD deploy it through an infra-storage-app Application. Then any PVC that uses storageClassName: local-path will get local disk, so your stateful apps are fully supported in your self-hosted GitOps pipeline setup.
For clusters with multiple nodes, use shared storage like NFS or Ceph/Rook, and manage its configuration in Git as part of your self-hosted GitOps pipeline setup.
Secure Git Connectivity
If your cluster is on a private network, your self-hosted GitOps pipeline setup still needs a secure way to reach the Git server. Headscale, a self-hosted Tailscale control server, lets you do that using access rules and an encrypted WireGuard network.
Typical approach:
- Deploy Headscale and join Kubernetes nodes and the Git server/VM as clients.
- Use Headscale ACLs to restrict which IPs can reach Git.
- Write Kubernetes
NetworkPolicy objects to allow egress from argocd namespace to Headscale IP ranges and block other outbound access.
If you want a full setup of deploying Headscale as your self-hosted mesh VPN control plane, check this guide on self-hosting Headscale on Ubuntu, including TLS, reverse proxy, and client setup.
Connect ArgoCD to the GitOps Repository
Now you must wire ArgoCD to the Git repository that defines your self-hosted GitOps pipeline setup.
Register HTTPS repository:
argocd repo add https://github.com/your-org/gitops.git
argocd repo add [email protected]:your-org/gitops.git \ --ssh-private-key-path ~/.ssh/id_rsa
Create root app from CLI:
argocd app create root-app \ --repo https://github.com/your-org/gitops.git \ --path clusters/prod \ --dest-server https://kubernetes.default.svc \ --dest-namespace argocd \ --sync-policy automated
ArgoCD Automated Rollback and Health Checks
Use ArgoCD’s automatic sync and self-heal features with health checks so your self-hosted GitOps pipeline setup can recover quickly from bad deployments.
In Application specs for critical workloads:
1spec:2 syncPolicy:3 automated:4 prune: true5 selfHeal: true6 syncOptions:7 - CreateNamespace=true
ArgoCD keeps checking whether your resources are healthy, and if a new change breaks things, it automatically goes back to the last working version. That way, your self-hosted GitOps pipeline setup can stay reliable.
Conclusion
Building a modern self-hosted GitOps pipeline setup on bare-metal or dedicated servers means solving things that cloud Kubernetes usually does for you. Such as load balancing without a cloud LB, storage classes without cloud disks, private registry access without IAM, and secure Git access without a managed service mesh.
By using ArgoCD, MetalLB, NGINX Ingress, SOPS, and Headscale/WireGuard together, you get a Git-driven, health-checked, fully self-managed GitOps pipeline setup that’s ready for production.
We hope you enjoy this guide.