ci: build, push and deploy to personal cluster on push to master

- Gitea Actions workflow: build, push sha-tagged image, apply kustomize, wait for rollout with auto-rollback
- Deploy to personal-site namespace, served at jesseinit.dev with TLS
- Run container as non-root and bind to 0.0.0.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jesse Egbosionu
2026-10-24 21:19:22 +02:00
co-authored by Claude Opus 5.5
parent 3bb0a5e4a9
commit 0927b9d665
8 changed files with 228 additions and 242 deletions
+3 -1
View File
@@ -5,4 +5,6 @@ npm-debug.log
README.md README.md
.env* .env*
.next .next
.git .git
.github
k8s
+140
View File
@@ -0,0 +1,140 @@
name: CI and Deploy
on:
workflow_dispatch:
pull_request:
branches:
- master
push:
branches:
- master
env:
REGISTRY: tea.jesseinit.dev
IMAGE_NAME: ${{ github.repository_owner }}/personal-site
NAMESPACE: personal-site
DEPLOYMENT: jesseinit-portfolio
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
ci:
name: Build site
runs-on: ubuntu-latest
timeout-minutes: 15
# The Gitea runner image only caches Node 24; match the Dockerfile's Node 22.
container: node:22-bookworm
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Validate Kubernetes manifests
run: |
curl -fsSL "https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl
chmod +x /usr/local/bin/kubectl
kubectl kustomize k8s/ > /dev/null
build-image:
name: Build and push image
needs: ci
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=sha,format=long
- name: Build and push image
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
deploy:
name: Deploy to Kubernetes
needs: build-image
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up kubectl
uses: azure/setup-kubectl@v5.1.0
with:
version: v1.34.3
- name: Configure kubectl
env:
KUBE_CONFIG_B64: ${{ secrets.KUBE_CONFIG }}
run: |
set -euo pipefail
if [ -z "${KUBE_CONFIG_B64}" ]; then
echo "::error::KUBE_CONFIG is not configured in the production environment or repository."
exit 1
fi
KUBECONFIG_PATH="${RUNNER_TEMP}/personal-site-kubeconfig"
umask 077
printf '%s' "${KUBE_CONFIG_B64}" | base64 --decode > "${KUBECONFIG_PATH}"
echo "KUBECONFIG=${KUBECONFIG_PATH}" >> "${GITHUB_ENV}"
KUBECONFIG="${KUBECONFIG_PATH}" kubectl cluster-info --request-timeout=15s
- name: Pin image to this commit
run: sed -i "s/newTag: latest/newTag: sha-${GITHUB_SHA}/" k8s/kustomization.yaml
- name: Apply manifests
run: kubectl apply -k k8s/
- name: Wait for rollout
run: |
if ! kubectl rollout status "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" --timeout=5m; then
kubectl get pods -n "${NAMESPACE}" -o wide
kubectl describe pods -n "${NAMESPACE}" | tail -40
kubectl rollout undo "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" || true
exit 1
fi
- name: Verify deployment
run: |
kubectl get deployments,pods,services,ingress -n "${NAMESPACE}"
echo "### Deployed sha-${GITHUB_SHA} to ${NAMESPACE}" >> "${GITHUB_STEP_SUMMARY}"
+8 -4
View File
@@ -16,8 +16,12 @@ RUN npm run build
# Run # Run
FROM base AS run FROM base AS run
ENV NODE_ENV=production ENV NODE_ENV=production
RUN mkdir .next ENV HOSTNAME=0.0.0.0
COPY --from=build /app/public ./public ENV PORT=3000
COPY --from=build /app/.next/standalone ./ RUN mkdir .next && chown node:node .next
COPY --from=build /app/.next/static ./.next/static COPY --from=build --chown=node:node /app/public ./public
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"] CMD ["node", "server.js"]
+31 -214
View File
@@ -1,225 +1,42 @@
# Kubernetes Deployment Guide # Kubernetes deployment
This directory contains all the necessary Kubernetes manifests to deploy the jesseinit portfolio application. The site runs on the personal k3s cluster in the `personal-site` namespace and is served at
https://jesseinit.dev (`www.` redirects to the apex). TLS comes from cert-manager (`letsencrypt-prod`).
## Prerequisites ## How deploys work
- Kubernetes cluster (local or cloud) `.github/workflows/deploy.yml` (run by Gitea Actions) on every push to `master`:
- kubectl CLI installed and configured
- Docker for building the image
- (Optional) kustomize for managing configurations
## Files Overview 1. **Build site**: `npm ci && npm run build`, and renders the kustomization to catch manifest errors.
2. **Build and push image**: `tea.jesseinit.dev/jesseinit/personal-site:sha-<commit>` and `:latest`.
3. **Deploy**: pins `newTag` in `kustomization.yaml` to `sha-<commit>`, runs `kubectl apply -k k8s/`,
waits for the rollout, and rolls back automatically if it fails.
- `deployment.yaml` - Kubernetes Deployment with 2 replicas, health checks, and resource limits Pull requests run step 1 only. Old images are cleaned up by the nightly `gitea-package-prune` cronjob.
- `service.yaml` - ClusterIP Service exposing the application on port 80
- `ingress.yaml` - Ingress resource for external access (requires ingress controller)
- `configmap.yaml` - ConfigMap for environment variables
- `kustomization.yaml` - Kustomize configuration for easy deployment management
## Quick Start ### Required secrets (repo or `production` environment in Gitea)
### 1. Build the Docker Image | Secret | Value |
| ---------------- | ----------------------------------------------------- |
| `REGISTRY_TOKEN` | Gitea token with `write:package` for `jesseinit` |
| `KUBE_CONFIG` | base64-encoded kubeconfig for the personal cluster |
## Files
- `namespace.yaml`: `personal-site` namespace
- `deployment.yaml`: 2 replicas, zero-downtime rolling updates, non-root, health checks
- `service.yaml`: ClusterIP on port 80 → container port 3000
- `ingress.yaml`: nginx ingress + TLS for `jesseinit.dev` / `www.jesseinit.dev`
- `configmap.yaml`: optional environment variables
- `kustomization.yaml`: ties it together and sets the image
## Manual operations
```bash ```bash
# Build the image kubectl --context personal-cluster -n personal-site get pods,ingress,certificate
docker build -t jesseinit-portfolio:latest . kubectl --context personal-cluster -n personal-site logs -l app=jesseinit-portfolio -f
kubectl --context personal-cluster -n personal-site rollout undo deployment/jesseinit-portfolio
# If using a registry, tag and push # Deploy by hand (uses :latest)
docker tag jesseinit-portfolio:latest <your-registry>/jesseinit-portfolio:latest kubectl --context personal-cluster apply -k k8s/
docker push <your-registry>/jesseinit-portfolio:latest
``` ```
### 2. Update Image Reference (if using a registry)
Edit `k8s/kustomization.yaml` and update the image reference:
```yaml
images:
- name: jesseinit-portfolio
newName: <your-registry>/jesseinit-portfolio
newTag: latest
```
### 3. Deploy to Kubernetes
#### Option A: Using kubectl
```bash
# Apply all manifests
kubectl apply -f k8s/
# Verify deployment
kubectl get pods -l app=jesseinit-portfolio
kubectl get svc jesseinit-portfolio
kubectl get ingress jesseinit-portfolio
```
#### Option B: Using Kustomize
```bash
# Deploy using kustomize
kubectl apply -k k8s/
# Or build and preview first
kubectl kustomize k8s/ | kubectl apply -f -
```
### 4. Configure Ingress (Optional)
Edit `k8s/ingress.yaml` to match your setup:
1. Update the `host` field with your domain
2. Set the correct `ingressClassName` (nginx, traefik, etc.)
3. Uncomment TLS section if using HTTPS
4. Add appropriate annotations for your ingress controller
Example for NGINX with cert-manager:
```yaml
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
```
### 5. Add Environment Variables (Optional)
Edit `k8s/configmap.yaml` to add any required environment variables:
```yaml
data:
API_URL: "https://api.example.com"
NEXT_PUBLIC_SITE_URL: "https://jesseinit.com"
```
## Local Development with Minikube
```bash
# Start minikube
minikube start
# Build image in minikube's Docker daemon
eval $(minikube docker-env)
docker build -t jesseinit-portfolio:latest .
# Deploy
kubectl apply -k k8s/
# Access the application
minikube service jesseinit-portfolio
```
## Monitoring
### Check Pod Status
```bash
kubectl get pods -l app=jesseinit-portfolio
kubectl logs -l app=jesseinit-portfolio -f
kubectl describe pod <pod-name>
```
### Check Service
```bash
kubectl get svc jesseinit-portfolio
kubectl describe svc jesseinit-portfolio
```
### Port Forward for Local Testing
```bash
kubectl port-forward svc/jesseinit-portfolio 8080:80
# Access at http://localhost:8080
```
## Scaling
```bash
# Scale to 3 replicas
kubectl scale deployment jesseinit-portfolio --replicas=3
# Or edit deployment.yaml and reapply
```
## Updating the Application
```bash
# Build new image with new tag
docker build -t jesseinit-portfolio:v2 .
docker push <your-registry>/jesseinit-portfolio:v2
# Update the image
kubectl set image deployment/jesseinit-portfolio portfolio=<your-registry>/jesseinit-portfolio:v2
# Or update kustomization.yaml and reapply
kubectl apply -k k8s/
```
## Troubleshooting
### Pods not starting
```bash
kubectl describe pod <pod-name>
kubectl logs <pod-name>
```
### Service not accessible
```bash
kubectl get endpoints jesseinit-portfolio
kubectl describe svc jesseinit-portfolio
```
### Image pull errors
If using a private registry, create an image pull secret:
```bash
kubectl create secret docker-registry regcred \
--docker-server=<your-registry> \
--docker-username=<username> \
--docker-password=<password> \
--docker-email=<email>
```
Then add to `deployment.yaml`:
```yaml
spec:
imagePullSecrets:
- name: regcred
```
## Resource Management
The deployment includes default resource requests and limits:
- Requests: 128Mi memory, 100m CPU
- Limits: 512Mi memory, 500m CPU
Adjust these in `deployment.yaml` based on your application's needs.
## Cleanup
```bash
# Delete all resources
kubectl delete -k k8s/
# Or delete individually
kubectl delete -f k8s/
```
## Production Considerations
1. **Use a specific image tag** instead of `latest` for better version control
2. **Set up HorizontalPodAutoscaler** for automatic scaling
3. **Configure persistent storage** if needed (for logs, cache, etc.)
4. **Add network policies** for security
5. **Set up monitoring** with Prometheus/Grafana
6. **Configure TLS/SSL** certificates for HTTPS
7. **Use secrets** for sensitive data instead of ConfigMaps
8. **Implement resource quotas** and limit ranges
9. **Set up backup and disaster recovery** procedures
10. **Use namespace isolation** for multi-environment deployments
+14
View File
@@ -6,6 +6,12 @@ metadata:
app: jesseinit-portfolio app: jesseinit-portfolio
spec: spec:
replicas: 2 replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector: selector:
matchLabels: matchLabels:
app: jesseinit-portfolio app: jesseinit-portfolio
@@ -24,12 +30,20 @@ spec:
env: env:
- name: NODE_ENV - name: NODE_ENV
value: "production" value: "production"
- name: HOSTNAME
value: "0.0.0.0"
- name: PORT
value: "3000"
- name: NEXT_TELEMETRY_DISABLED - name: NEXT_TELEMETRY_DISABLED
value: "1" value: "1"
envFrom: envFrom:
- configMapRef: - configMapRef:
name: jesseinit-portfolio-config name: jesseinit-portfolio-config
optional: true optional: true
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
resources: resources:
requests: requests:
memory: "128Mi" memory: "128Mi"
+20 -19
View File
@@ -5,24 +5,25 @@ metadata:
labels: labels:
app: jesseinit-portfolio app: jesseinit-portfolio
annotations: annotations:
# Uncomment and configure based on your ingress controller cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
# cert-manager.io/cluster-issuer: "letsencrypt-prod" # Redirect www.jesseinit.dev -> jesseinit.dev
nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
spec: spec:
ingressClassName: nginx # Change to your ingress class (nginx, traefik, etc.) ingressClassName: nginx
tls:
- hosts:
- jesseinit.dev
- www.jesseinit.dev
secretName: personal-site-tls
rules: rules:
- host: jesseinit.com # Replace with your domain - host: jesseinit.dev
http: http:
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: jesseinit-portfolio name: jesseinit-portfolio
port: port:
number: 80 number: 80
# Uncomment for TLS/HTTPS support
# tls:
# - hosts:
# - jesseinit.com
# secretName: jesseinit-portfolio-tls
+8 -4
View File
@@ -1,18 +1,22 @@
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization
namespace: default namespace: personal-site
resources: resources:
- namespace.yaml
- configmap.yaml - configmap.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- ingress.yaml - ingress.yaml
commonLabels: labels:
app: jesseinit-portfolio - pairs:
app.kubernetes.io/name: personal-site
app.kubernetes.io/managed-by: kustomize
# CI rewrites newTag to sha-<commit> before applying.
images: images:
- name: jesseinit-portfolio - name: jesseinit-portfolio
newName: jesseinit-portfolio newName: tea.jesseinit.dev/jesseinit/personal-site
newTag: latest newTag: latest
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: personal-site