diff --git a/.dockerignore b/.dockerignore index 6f7e304..df51880 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,6 @@ npm-debug.log README.md .env* .next -.git \ No newline at end of file +.git +.github +k8s diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..83c658a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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}" diff --git a/Dockerfile b/Dockerfile index 238b74d..f223b2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,12 @@ RUN npm run build # Run FROM base AS run ENV NODE_ENV=production -RUN mkdir .next -COPY --from=build /app/public ./public -COPY --from=build /app/.next/standalone ./ -COPY --from=build /app/.next/static ./.next/static +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 +RUN mkdir .next && chown node:node .next +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"] \ No newline at end of file diff --git a/k8s/README.md b/k8s/README.md index 8077fa4..446226d 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -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) -- kubectl CLI installed and configured -- Docker for building the image -- (Optional) kustomize for managing configurations +`.github/workflows/deploy.yml` (run by Gitea Actions) on every push to `master`: -## 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-` and `:latest`. +3. **Deploy**: pins `newTag` in `kustomization.yaml` to `sha-`, 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 -- `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 +Pull requests run step 1 only. Old images are cleaned up by the nightly `gitea-package-prune` cronjob. -## 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 -# Build the image -docker build -t jesseinit-portfolio:latest . +kubectl --context personal-cluster -n personal-site get pods,ingress,certificate +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 -docker tag jesseinit-portfolio:latest /jesseinit-portfolio:latest -docker push /jesseinit-portfolio:latest +# Deploy by hand (uses :latest) +kubectl --context personal-cluster apply -k k8s/ ``` - -### 2. Update Image Reference (if using a registry) - -Edit `k8s/kustomization.yaml` and update the image reference: - -```yaml -images: - - name: jesseinit-portfolio - newName: /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 -``` - -### 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 /jesseinit-portfolio:v2 - -# Update the image -kubectl set image deployment/jesseinit-portfolio portfolio=/jesseinit-portfolio:v2 - -# Or update kustomization.yaml and reapply -kubectl apply -k k8s/ -``` - -## Troubleshooting - -### Pods not starting - -```bash -kubectl describe pod -kubectl logs -``` - -### 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= \ - --docker-username= \ - --docker-password= \ - --docker-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 diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 2d5c6cd..1bd05dc 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -6,6 +6,12 @@ metadata: app: jesseinit-portfolio spec: replicas: 2 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 selector: matchLabels: app: jesseinit-portfolio @@ -24,12 +30,20 @@ spec: env: - name: NODE_ENV value: "production" + - name: HOSTNAME + value: "0.0.0.0" + - name: PORT + value: "3000" - name: NEXT_TELEMETRY_DISABLED value: "1" envFrom: - configMapRef: name: jesseinit-portfolio-config optional: true + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false resources: requests: memory: "128Mi" diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml index cea6c66..74cd742 100644 --- a/k8s/ingress.yaml +++ b/k8s/ingress.yaml @@ -5,24 +5,25 @@ metadata: labels: app: jesseinit-portfolio annotations: - # Uncomment and configure based on your ingress controller - # nginx.ingress.kubernetes.io/ssl-redirect: "true" - # cert-manager.io/cluster-issuer: "letsencrypt-prod" + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + # Redirect www.jesseinit.dev -> jesseinit.dev + nginx.ingress.kubernetes.io/from-to-www-redirect: "true" 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: - - host: jesseinit.com # Replace with your domain - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: jesseinit-portfolio - port: - number: 80 - # Uncomment for TLS/HTTPS support - # tls: - # - hosts: - # - jesseinit.com - # secretName: jesseinit-portfolio-tls + - host: jesseinit.dev + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: jesseinit-portfolio + port: + number: 80 diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index e3913fd..dc09e2c 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -1,18 +1,22 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -namespace: default +namespace: personal-site resources: + - namespace.yaml - configmap.yaml - deployment.yaml - service.yaml - ingress.yaml -commonLabels: - app: jesseinit-portfolio +labels: + - pairs: + app.kubernetes.io/name: personal-site + app.kubernetes.io/managed-by: kustomize +# CI rewrites newTag to sha- before applying. images: - name: jesseinit-portfolio - newName: jesseinit-portfolio + newName: tea.jesseinit.dev/jesseinit/personal-site newTag: latest diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..836ce74 --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: personal-site