Self Hosting

From localhost to Production: Deploy Your React App on Self-Hosted Kubernetes

December 18, 2025
15 min min read
intermediate
kubernetes
react
docker
ghcr
self-hosted-devops
From localhost to Production: Deploy Your React App on Self-Hosted Kubernetes

From localhost to Production: Deploy Your React App on Self-Hosted Kubernetes

You’ve got a Kubernetes cluster humming along. Certificates are green. Ingress is configured. And yet, every project you build still lives at localhost:5173 like it’s paying rent there. Time to evict it.

This guide walks through the actual deployment pipeline: from scaffolding a React app to pushing a container image to GitHub Container Registry (GHCR) and orchestrating it on your self-hosted cluster with TLS termination. No managed services. No cloud vendor lock-in. Just you, your cluster, and a working production URL.

Why Self-Hosted?

Cloud providers offer convenience at the cost of control and recurring fees. A self-hosted cluster gives you full ownership of your infrastructure, predictable costs, and the satisfaction of knowing exactly where your bits live. The trade-off is operational responsibility—but if you're reading this, you've already accepted that bargain.

Init

What you need

Before diving in, ensure you have:

  • A working Kubernetes cluster (k3s, kubeadm, or similar)

  • kubectl configured and pointing to your cluster

  • docker installed locally for building images

  • GitHub account

  • Node.js and yarn for local development

  • A domain pointing to your cluster’s ingress controller

  • cert-manager configured with a ClusterIssuer (for TLS)

Apply

Create and Test Your React Project Locally

Spin up a fresh React project using Vite. The --template react flag gives you a minimal, modern setup without the bloat of create-react-app.

Shell
yarn create vite react --template react

When prompted, accept the defaults. Vite will scaffold the project, install dependencies, and drop you into a ready-to-run state.

To start project again after scaffolding

Shell
cd react
yarn dev

Then open http://localhost:5173 in your browser. You should see the default Vite + React welcome page. Congratulations—your project works. Locally. Like every other project you’ve abandoned in your ~/projects graveyard.

Prepare the Project for Docker

To ship this beyond your machine, you need a container. Create two files in your project root: a Dockerfile for the build process and a .dockerignore to keep the image lean.

Multi-Stage Builds

This Dockerfile uses a multi-stage build. Stage one compiles your React app with Node. Stage two copies only the static output into an NGINX image. The result is a production image under 50MB instead of 1GB+ of node_modules debris.

Dockerfile

Dockerfile

.dockerignore

Plain Text
node_modules
build
dist
.git
.gitignore
*.log
.DS_Store
.env
.env.local

Build and Push to GitHub Container Registry

GHCR provides free container hosting tied to your GitHub account. Authenticate Docker with your Personal Access Token, build the image, and push it.

First, export your token:

bash

Shell
export GITHUB_TOKEN=ghp_your_token_here

fish

Shell
set -gx GITHUB_TOKEN ghp_your_token_here

Login to GHCR

Shell
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
#                                             ↑ replace with your GitHub username

Expected output

Shell
WARNING! Your credentials are stored unencrypted in '/home/user/.docker/config.json'.
Configure a credential helper to remove this warning. See
https://docs.docker.com/go/credential-store/

Login Succeeded

Build the image with your GHCR path

Shell
docker build -t ghcr.io/YOUR_GITHUB_USERNAME/react:latest .
#                        ↑ replace with your GitHub username in lowercase

The build will pull base images, install dependencies, compile your app, and package it into the NGINX runtime. Output will show each layer being processed—grab coffee if your network is slow.

Push to the registry:

Shell
docker push ghcr.io/YOUR_GITHUB_USERNAME/react:latest
#                   ↑ replace with your GitHub username in lowercase

Once complete, your image lives at ghcr.io/YOUR_GITHUB_USERNAME/react:latest. You can verify it exists in your GitHub profile under Packages.

Package Visibility

By default, GHCR packages are private. Either make the package public in GitHub's UI, or configure imagePullSecrets (covered next) to authenticate your cluster.

Real-world Application

Ah yes, the classic developer flex: “Check out this app I built!” followed by the crushing silence when someone asks for the URL and you mumble something about port 5173 and SSH tunnels. Your mom doesn’t want to install kubectl to see your todo app.

What follows is the unsexy middle: YAML files, registry authentication, ingress configuration. It’s not hard, but it’s not nothing. Let’s fix your localhost shame—and in the process, you’ll understand exactly why platforms like Vercel charge what they do (and why ZipOps exists as an alternative).

Step 1: Create the Namespace

Isolate your deployment in its own namespace. Clean separation, easy cleanup if things go sideways.

namespace.yaml

YAML
apiVersion: v1
kind: Namespace
metadata:
  name: test-react

And apply it as first resource

Shell
kubectl apply -f namespace.yaml

Step 2: Create GHCR Pull Secret

Your cluster needs credentials to pull from your private GHCR repository. Create a docker-registry secret:

Shell
kubectl create secret docker-registry ghcr-secret \
  --docker-server=ghcr.io \
  --docker-username=YOUR_GITHUB_USERNAME \ # <-- your username
  --docker-password=$GITHUB_TOKEN \        # <-- your token from env var
  --docker-email=YOUR_EMAIL \              # <-- your email
  -n test-react
Why Not Store the Token in YAML?

Hardcoding secrets in version-controlled files is a security anti-pattern. The imperative kubectl create secret approach keeps credentials out of your git history. For production, consider external secret management (Vault, Sealed Secrets, External Secrets Operator).

Step 3: Deploy the Application

The Deployment resource tells Kubernetes how to run your container: which image, how many replicas, resource limits, and pull credentials.

deployment.yaml

YAML

Step 4: Expose with a Service

A ClusterIP Service gives your deployment a stable internal endpoint that other cluster resources (like Ingress) can reference.

service.yaml

YAML

Step 5: Configure Ingress with TLS

The Ingress resource routes external traffic to your service and handles TLS termination via cert-manager. This example uses Cilium as the ingress controller—adjust ingressClassName if you’re running Traefik, NGINX, or another controller.

ingress.yaml

YAML

Step 6: apply them all

Apply all resources

Shell
kubectl apply -f deployment.yaml -f service.yaml -f ingress.yaml

and verify the pod is running

Shell
kubectl get pods -n test-react
TLS Certificate Provisioning

After applying the Ingress, cert-manager will initiate an ACME HTTP-01 challenge to verify domain ownership and issue a certificate. This typically takes 30~90 seconds. Monitor progress with kubectl get certificate -n test-react

Wait until READY shows True, before accessing your site

Once the certificate is ready, navigate to https://react.yourdomain.com. Your React app—the same one that lived at localhost an hour ago—is now live on the internet with proper TLS. Send that URL to your mom.

Rather ship than script?

You just wrote 5 YAML files, configured registry secrets, and waited for certificate provisioning. ZipOps reduces this to: run zipops -→ set values -→ deploy. Same Cilium ingress, same Let's Encrypt certs, zero manifests. See the workflow →

Deploying Updates


<-- Back to Blog <-- Back to Self Hosting