feat: add Woodpecker CI pipeline, Dockerfile, and Helm chart for K8s deployment
Some checks failed
ci/someci/push/woodpecker Pipeline failed

Static site served via nginx-unprivileged on ITSH cloud (tenant-2).
Pipeline: lint → docker build+push to somegit.dev → helm deploy.
Includes HTTPRoute with TLS, HTTP→HTTPS redirect, health probes,
and hardened security context.
This commit is contained in:
2026-03-10 11:38:08 +01:00
parent af0ed4ea4f
commit 2f885c3ca7
14 changed files with 395 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.svelte-kit
.git
.idea
.claude
*.log

47
.woodpecker.yml Normal file
View File

@@ -0,0 +1,47 @@
when:
- event: [push, pull_request]
steps:
lint:
image: node:24-alpine
commands:
- npm ci
- npx prettier --check .
- npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json
when:
- event: [push, pull_request]
docker:
image: woodpeckerci/plugin-docker-buildx:6
settings:
repo: somegit.dev/nachtigall.dev/nachtigall.dev
tags:
- '${CI_COMMIT_SHA:0:8}'
dockerfile: Dockerfile
registry: somegit.dev
username:
from_secret: registry_user
password:
from_secret: registry_password
when:
- event: push
branch: main
deploy:
image: alpine/helm:4.1
environment:
KUBECONFIG_DATA:
from_secret: kubeconfig
commands:
- mkdir -p ~/.kube
- echo "$KUBECONFIG_DATA" > ~/.kube/config
- chmod 600 ~/.kube/config
- |
helm upgrade --install nachtigall-dev ./deploy/helm/ \
--namespace tenant-2 \
--set image.tag="${CI_COMMIT_SHA:0:8}" \
--atomic \
--timeout 5m
when:
- event: push
branch: main

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# Stage 1: Build static site
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginxinc/nginx-unprivileged:1.29-alpine
COPY deploy/nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 8080

5
deploy/helm/Chart.yaml Normal file
View File

@@ -0,0 +1,5 @@
apiVersion: v2
name: nachtigall-dev
description: Static portfolio site for nachtigall.dev
type: application
version: 0.1.0

View File

@@ -0,0 +1,24 @@
{{- define "nachtigall-dev.name" -}}
{{- .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "nachtigall-dev.fullname" -}}
{{- $name := .Chart.Name }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "nachtigall-dev.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
app.kubernetes.io/name: {{ include "nachtigall-dev.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "nachtigall-dev.selectorLabels" -}}
app.kubernetes.io/name: {{ include "nachtigall-dev.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "nachtigall-dev.fullname" . }}
labels:
{{- include "nachtigall-dev.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "nachtigall-dev.selectorLabels" . | nindent 6 }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
{{- include "nachtigall-dev.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "nachtigall-dev.fullname" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}

View File

@@ -0,0 +1,22 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "nachtigall-dev.fullname" . }}
labels:
{{- include "nachtigall-dev.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "nachtigall-dev.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}

View File

@@ -0,0 +1,22 @@
{{- if .Values.httproute.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "nachtigall-dev.fullname" . }}-redirect
labels:
{{- include "nachtigall-dev.labels" . | nindent 4 }}
spec:
parentRefs:
- name: {{ .Values.httproute.gateway.name }}
namespace: {{ .Values.httproute.gateway.namespace }}
sectionName: http
hostnames:
- {{ .Values.httproute.hostname | quote }}
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
backendRefs: []
{{- end }}

View File

@@ -0,0 +1,21 @@
{{- if .Values.httproute.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "nachtigall-dev.fullname" . }}
labels:
{{- include "nachtigall-dev.labels" . | nindent 4 }}
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
parentRefs:
- name: {{ .Values.httproute.gateway.name }}
namespace: {{ .Values.httproute.gateway.namespace }}
sectionName: https-{{ .Values.httproute.hostname | replace "." "-" }}
hostnames:
- {{ .Values.httproute.hostname | quote }}
rules:
- backendRefs:
- name: {{ include "nachtigall-dev.fullname" . }}
port: {{ .Values.service.port }}
{{- end }}

View File

@@ -0,0 +1,13 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "nachtigall-dev.fullname" . }}
labels:
{{- include "nachtigall-dev.labels" . | nindent 4 }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels:
{{- include "nachtigall-dev.selectorLabels" . | nindent 6 }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "nachtigall-dev.fullname" . }}
labels:
{{- include "nachtigall-dev.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "nachtigall-dev.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,9 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "nachtigall-dev.fullname" . }}
labels:
{{- include "nachtigall-dev.labels" . | nindent 4 }}
automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }}
{{- end }}

72
deploy/helm/values.yaml Normal file
View File

@@ -0,0 +1,72 @@
replicaCount: 1
image:
repository: somegit.dev/nachtigall.dev/nachtigall.dev
tag: latest
pullPolicy: IfNotPresent
imagePullSecrets:
- name: somegit
serviceAccount:
create: true
automountServiceAccountToken: false
service:
type: ClusterIP
port: 80
targetPort: 8080
httproute:
enabled: true
hostname: nachtigall.dev
gateway:
name: default
namespace: nginx-gateway
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
podSecurityContext:
runAsNonRoot: true
runAsUser: 101 # nginx-unprivileged default UID
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 5
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 70
podDisruptionBudget:
enabled: false
minAvailable: 1

73
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,73 @@
worker_processes auto;
pid /tmp/nginx.pid;
events {
worker_connections 512;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Temp paths for unprivileged operation
client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;
sendfile on;
tcp_nopush on;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
image/svg+xml;
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Health check endpoint
location = /healthz {
access_log off;
return 200 "ok";
}
# SvelteKit immutable assets (content-hashed)
location /_app/immutable/ {
expires max;
add_header Cache-Control "public, immutable";
}
# Other static assets
location ~* \.(css|js|svg|png|jpg|jpeg|webp|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public";
}
# Clean URLs: /contact → /contact.html
location / {
try_files $uri $uri.html $uri/ =404;
}
# Custom 404
error_page 404 /404.html;
}
}