On-Premises & Self-Hosted Deployment¶
What this lesson gives you
When regulation, data gravity, or latency forces compute inside the firewall, the pipeline still orchestrates it — the self-hosted runner pulls jobs via outbound connections, so no inbound holes are needed.
Estimated time: 16 min read · Part: CI/CD Across Cloud, On-Prem & Hybrid
Why On-Premises Still Exists¶
Every year, predictions of the death of on-premises computing fail to materialize. The reason is that for a significant portion of enterprise workloads, on-prem is not a legacy choice — it is the technically correct one. Understanding the four reasons it persists is essential for a consultant who will encounter clients in every quadrant.
Compliance and data residency is the most common hard constraint. GDPR requires that EU citizen data not leave EU jurisdictions without appropriate safeguards. HIPAA requires strict control over protected health information (PHI) and audit trails on every access. FedRAMP requires US government data to stay in FedRAMP-authorized environments. Financial regulations (MiFID II, SOX, PCI-DSS) impose audit requirements and residency controls. While cloud providers now offer regional data residency controls and have obtained many compliance certifications, some organizations’ legal or risk teams require on-prem as the only acceptable path — and in some jurisdictions and sectors, they are right.
Data gravity applies when the dataset is so large that moving it to the cloud is economically impractical. A genomics lab with 5 petabytes of sequencing data on local storage does not move that data to the cloud for analysis — it brings the compute to the data. An internet exchange point, a stock exchange, a weather modeling agency: each has terabyte-per-second data streams that gravity anchors to on-prem hardware.
Latency-critical applications include manufacturing automation (PLCs, SCADA systems), robotics, high-frequency trading, real-time control systems for medical devices, and edge computing for autonomous vehicles. These applications require single-digit millisecond response times or deterministic scheduling that cloud networks — with their inherent latency variability — cannot guarantee.
Economics at scale: at very large numbers of nodes (typically above 500–1,000 equivalent machines running continuously), the amortized cost of owned hardware often undercuts cloud pricing. Large streaming companies, social platforms, and financial institutions have made this calculation and operate their own hardware, sometimes alongside cloud bursting capacity.
The Self-Hosted Runner Model: Pull, Not Push¶
The key architectural insight for deploying to on-prem from a cloud-based CI system is the direction of the connection. A naive approach would be to have the CI system push code or commands into the on-prem network — which would require opening inbound firewall ports and creating a complex network exposure. The correct model inverts this.
A self-hosted runner (GitHub Actions terminology; analogous to a GitLab Runner, Jenkins agent, or Buildkite agent) is a process that runs inside the corporate network. It initiates an outbound HTTPS connection to the CI control plane (GitHub, GitLab.com, or your self-hosted CI server) and polls for available jobs. When a job matching its labels is queued, the runner pulls the job definition, executes it locally, and reports results back via the same outbound connection. The runner can reach any host inside the corporate network, including the production Kubernetes cluster, database hosts, or artifact servers — because it is physically inside the perimeter.
The Outbound-Pull Analogy
Think of the self-hosted runner like an employee who calls the office each morning to ask “what should I work on today?” The office never sends anyone to the employee’s location; the employee (runner) comes out to pick up their assignment and then returns inside to do the work. Corporate security only needs to allow that outbound call — no one from outside ever crosses the security checkpoint.
flowchart TB
subgraph CLOUD["GitHub (Cloud)"]
GH["GitHub Actions<br/>Control Plane<br/>(queue jobs)"]
end
subgraph CORP["Corporate Network (On-Prem)"]
direction TB
R["Self-Hosted Runner<br/>(outbound HTTPS only)"]
K8S["On-Prem Kubernetes<br/>Cluster"]
DB[("Regulated<br/>Database")]
R -->|kubectl apply| K8S
K8S --- DB
end
GH <-->|"Outbound HTTPS port 443<br/>(runner initiates)"| R
style CLOUD fill:#1e3a5f,color:#e2e8f0
style CORP fill:#2a1e3a,color:#e2e8f0 The firewall rule is simple: allow outbound HTTPS (port 443) from the runner host to api.github.com, *.actions.githubusercontent.com, and the container registry. No inbound rules required.
Installing and Registering a Self-Hosted Runner¶
GitHub provides a runner binary for Linux, macOS, and Windows. The registration process uses a short-lived registration token from the GitHub API; thereafter the runner communicates using a long-lived but non-privileged credential that cannot be used to authenticate as the repository owner.
# 1. Download the runner binary (Linux x64 example)
mkdir -p /opt/github-runner && cd /opt/github-runner
curl -o actions-runner-linux-x64.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.316.0/actions-runner-linux-x64-2.316.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
# 2. Register against your repo or org
# --token is retrieved from Settings > Actions > Runners > New self-hosted runner
./config.sh \
--url https://github.com/myorg/myrepo \
--token ABCDEFGTOKEN123 \
--name prod-onprem-runner-01 \
--labels self-hosted,linux,onprem,production \
--runnergroup on-premises
# 3. Install as a systemd service so it survives reboots
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status
In the workflow, the runner is targeted by label so cloud-intended jobs run on GitHub-hosted runners and on-prem jobs run on self-hosted runners:
name: Deploy to On-Prem Kubernetes
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest # GitHub-hosted: build the image, push to Harbor
steps:
- uses: actions/checkout@v4
- name: Build and push to Harbor
run: |
docker build -t harbor.corp.internal/apps/api:${{ github.sha }} .
docker push harbor.corp.internal/apps/api:${{ github.sha }}
deploy:
runs-on: [self-hosted, onprem, production] # Self-hosted: inside the firewall
needs: build
steps:
# The kubeconfig for the on-prem cluster is stored as a CI secret
# (encrypted in GitHub, decrypted into the runner's environment)
- name: Set up kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.ONPREM_KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Deploy to on-prem cluster
run: |
kubectl set image deployment/api \
api=harbor.corp.internal/apps/api:${{ github.sha }} \
--namespace production
- name: Wait for rollout
run: |
kubectl rollout status deployment/api \
--namespace production \
--timeout=5m
Air-Gapped Environments and Internal Registries¶
Some environments go beyond “on-prem with internet access” to full air gaps: the network has no outbound internet connectivity at all. Every artifact must be pre-approved, scanned, and loaded onto internal mirrors before use. This is common in defense, intelligence, and the most security-conscious financial institutions.
Harbor is the CNCF-graduated on-premises container registry. It provides Docker Hub-compatible APIs, built-in vulnerability scanning (via Trivy or Clair), image signing, replication between Harbor instances, and robot accounts for CI authentication. In an air-gapped setup, Harbor is the single source of truth for container images: images are pulled from the internet by a designated admin workstation in a DMZ, scanned and signed, then mirrored into the air-gapped Harbor instance.
Artifactory or Nexus Repository serve as proxies and caches for language package managers: PyPI (for pip install), npm, Maven, NuGet, apt/yum/dnf. In an air-gapped environment, developers and CI runners point their package managers at the internal Artifactory rather than the internet. The Artifactory is pre-populated with approved packages by a separate process.
[global]
# Redirect all pip installs to internal Artifactory PyPI mirror
index-url = https://artifactory.corp.internal/artifactory/api/pypi/pypi-virtual/simple
trusted-host = artifactory.corp.internal
# Disable pip's check for newer pip versions (no internet)
disable-pip-version-check = 1
On-Premises Kubernetes Distributions¶
Once you’re deploying to on-prem Kubernetes, the deployment commands are identical to cloud K8s (kubectl apply, helm upgrade, kubectl set image). Only the kubeconfig endpoint changes. The distribution choice is an operational and commercial decision:
| Distribution | Vendor | Enterprise Support | GUI / Portal | Compliance Certs | Best For |
|---|---|---|---|---|---|
| Upstream Kubernetes | CNCF / community | None (or third-party) | Dashboard (basic) | None built-in | Teams with deep K8s expertise; full control |
| OpenShift | Red Hat (IBM) | Full enterprise SLA | Developer Console (rich) | FedRAMP, DoD IL4 | US government, healthcare, financial regulated workloads |
| Rancher / RKE2 | SUSE | Enterprise subscription | Rancher UI (multi-cluster) | FIPS 140-2 (RKE2) | Multi-cluster management, edge deployments |
| VMware Tanzu | Broadcom (VMware) | Full enterprise SLA | vSphere integration | Various | Shops already running VMware vSphere infrastructure |
Rollout Strategies On-Premises¶
Kubernetes supports multiple rollout strategies that work identically on-prem and in the cloud:
Rolling update (default): Kubernetes replaces pods one by one (or in configurable batches), ensuring maxUnavailable pods are replaced at a time and maxSurge extra pods can run during the transition. Zero downtime for stateless services. Config in the Deployment spec.
Blue/green: two full Deployments exist simultaneously (blue = current, green = new). Traffic is flipped from blue to green by updating a Service selector. Rollback is instantaneous: flip the selector back. Higher resource cost during the transition.
Canary: a small subset of pods (e.g., 5%) runs the new version; the rest runs the old. Traffic is split by replica ratio or by an ingress controller (Nginx, Istio). Metrics are evaluated; if acceptable, the canary is graduated to 100%.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: production
spec:
replicas: 6
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # At most 1 pod unavailable at any time
maxSurge: 2 # At most 2 extra pods running during update
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
version: "abc123f" # Updated per release for traceability
spec:
containers:
- name: api
image: harbor.corp.internal/apps/api:abc123f
ports:
- containerPort: 8000
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
Real-World Architecture: One Outbound Port
A regional bank ran its CI/CD on GitHub.com (cloud-hosted). Their production workloads ran on an on-premises OpenShift cluster in a co-location facility. The security team had a firm policy: no inbound connections from the internet to production systems. When approached about CI/CD automation, the initial reaction was: “impossible without a VPN.”
The solution: a GitHub Actions self-hosted runner deployed as a pod inside the OpenShift cluster itself. The runner pod made one outbound HTTPS connection to GitHub to pick up jobs. When a merge to main triggered a deployment workflow, the runner pod executed oc rollout commands against the local OpenShift API server — a loopback call that never left the data center. The firewall rule was exactly one line: outbound port 443 to GitHub IP ranges. The security team reviewed and approved it the same day.
Consulting Lens: “We Can’t Use the Cloud”
When a client opens with “we can’t use the cloud,” your first move is diagnostic: ask why before accepting it as a constraint. Sometimes it is a hard legal requirement (data residency, classification level) that cannot be argued. Sometimes it is an outdated assumption made five years ago that nobody has revisited. Often it is risk aversion that hasn’t been quantified.
The productive framing: “You don’t have to choose. We can deploy from one pipeline to both on-prem and cloud, with the same container image. On-prem is just a different kubeconfig. If the constraint is on data residency, we keep the data and its compute on-prem; we can still run the orchestration in the cloud.” This positions you as expanding the solution space rather than fighting the constraint.
Knowledge check
InterviewHow does a cloud-orchestrated CI/CD system deploy to servers inside a client’s firewall without opening an inbound network hole?
- The CI system opens an encrypted SSH tunnel to an agent inside the firewall using a pre-shared key stored in the cloud.
- A self-hosted runner inside the network initiates an outbound HTTPS connection to the CI control plane, polls for and pulls assigned jobs, then deploys to locally reachable targets — no inbound traffic is ever required.
- The CI system pushes to a DMZ-hosted relay server, which then forwards commands to the internal network over SMTP.
- A VPN is always required; there is no way to deploy on-premises without an inbound firewall rule.
Answer
A self-hosted runner inside the network initiates an outbound HTTPS connection to the CI control plane, polls for and pulls assigned jobs, then deploys to locally reachable targets — no inbound traffic is ever required.
The self-hosted runner is the key. It is a process that runs inside the corporate network and makes an outbound HTTPS connection to the CI control plane (GitHub, GitLab, Jenkins, Buildkite). The control plane queues jobs; the runner polls, picks up jobs matching its labels, executes them locally (with access to all internal systems), and reports results back. The firewall only needs to permit one outbound rule — port 443 to the CI provider’s IPs. No inbound connection, no VPN, no DMZ relay required.
Exercise
Exercise 13.2 · Self-Hosted Runner Deployment Write a GitHub Actions workflow that uses a self-hosted runner (labeled self-hosted,onprem,production) to deploy a new image to a Kubernetes deployment. Assume:
- The image was already built and pushed to
harbor.corp.internal/apps/worker:<sha>in a previous job. - A base64-encoded kubeconfig is available as the secret
PROD_KUBECONFIG_B64. - The deployment name is
workerin namespaceproduction.
Show solution
name: Deploy Worker On-Prem
on:
workflow_dispatch:
inputs:
sha:
description: Git SHA to deploy
required: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.sha }}
- name: Build and push to Harbor
run: |
docker build -t harbor.corp.internal/apps/worker:${{ github.event.inputs.sha }} .
docker push harbor.corp.internal/apps/worker:${{ github.event.inputs.sha }}
deploy:
runs-on: [self-hosted, onprem, production]
needs: build
steps:
- name: Configure kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.PROD_KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Update deployment image
run: |
kubectl set image deployment/worker \
worker=harbor.corp.internal/apps/worker:${{ github.event.inputs.sha }} \
--namespace production
- name: Wait for rollout to complete
run: |
kubectl rollout status deployment/worker \
--namespace production \
--timeout=10m
Key Takeaways
- On-premises computing persists for four legitimate reasons: data residency compliance, data gravity, latency requirements, and economics at scale — understand which applies before challenging a client’s constraint.
- Self-hosted runners use a pull model: the runner inside the firewall initiates an outbound HTTPS connection; no inbound holes in the firewall are required.
- Air-gapped environments rely on Harbor (container registry) and Artifactory/Nexus (package proxy) as internal mirrors; all runners, base images, and packages are pre-staged.
- On-prem K8s distributions (OpenShift, Rancher, Tanzu) are operationally equivalent to cloud K8s from the pipeline’s perspective — only the kubeconfig endpoint changes.