Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 116 additions & 12 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,61 @@
name: Deploy
name: Deploy

on:
on:
push:
branches:
- main

workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
name: "Build & Push Image"
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.tags }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

deploy:
name: "Deploy to VPS"
name: "Deploy to k3s"
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Configure SSH
run: |
mkdir -p ~/.ssh/
Expand All @@ -28,20 +72,80 @@ jobs:
SSH_USER: ${{ secrets.SSH_USER }}
SSH_IP: ${{ secrets.SSH_IP }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Create .env file on server

- name: Copy k8s manifests to server
run: |
ssh droplet "mkdir -p /root/portfolio && printf 'MYSQL_DATABASE=%s\nMYSQL_USER=%s\nMYSQL_PASSWORD=%s\nMYSQL_HOST=%s\nURL=%s\nCERTBOT_EMAIL=%s\nMARIADB_ROOT_PASSWORD=%s\nGRAFANA_PASSWORD=%s\n' '$MYSQL_DATABASE' '$MYSQL_USER' '$MYSQL_PASSWORD' '$MYSQL_HOST' '$URL' '$CERTBOT_EMAIL' '$MARIADB_ROOT_PASSWORD' '$GRAFANA_PASSWORD' > /root/portfolio/.env"
ssh droplet "mkdir -p /root/k8s"
scp -r k8s/* droplet:/root/k8s/

- name: Apply secrets (idempotent)
run: |
ssh droplet "
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml

# Backend secrets
kubectl -n portfolio create secret generic backend-secrets \
--from-literal=MYSQL_DATABASE='$MYSQL_DATABASE' \
--from-literal=MYSQL_USER='$MYSQL_USER' \
--from-literal=MYSQL_PASSWORD='$MYSQL_PASSWORD' \
--from-literal=MYSQL_HOST=mariadb \
--from-literal=URL='$URL' \
--dry-run=client -o yaml | kubectl apply -f -

# MariaDB secrets
kubectl -n portfolio create secret generic mariadb-secrets \
--from-literal=MYSQL_DATABASE='$MYSQL_DATABASE' \
--from-literal=MYSQL_USER='$MYSQL_USER' \
--from-literal=MYSQL_PASSWORD='$MYSQL_PASSWORD' \
--from-literal=MARIADB_ROOT_PASSWORD='$MARIADB_ROOT_PASSWORD' \
--dry-run=client -o yaml | kubectl apply -f -

# Grafana secrets
kubectl -n monitoring create secret generic grafana-secrets \
--from-literal=GRAFANA_PASSWORD='$GRAFANA_PASSWORD' \
--dry-run=client -o yaml | kubectl apply -f -
"
env:
MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }}
MYSQL_USER: ${{ secrets.MYSQL_USER }}
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
MYSQL_HOST: ${{ secrets.MYSQL_HOST }}
URL: ${{ secrets.URL }}
CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }}
MARIADB_ROOT_PASSWORD: ${{ secrets.MARIADB_ROOT_PASSWORD }}
URL: ${{ secrets.URL }}
GRAFANA_PASSWORD: ${{ secrets.GRAFANA_PASSWORD }}

- name: Deploy site
- name: Apply cert-manager issuer
run: |
ssh droplet "
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml

# Substitute CERTBOT_EMAIL in cert-issuer
sed -i 's/\${CERTBOT_EMAIL}/$CERTBOT_EMAIL/' /root/k8s/ingress/cert-issuer.yaml

kubectl apply -f /root/k8s/ingress/cert-issuer.yaml
"
env:
CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }}

- name: Apply k8s manifests
run: |
ssh droplet "
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml

# Apply namespaces first
kubectl apply -f /root/k8s/namespace.yaml

# Apply all manifests
kubectl apply -R -f /root/k8s/mariadb/
kubectl apply -R -f /root/k8s/backend/
kubectl apply -R -f /root/k8s/ingress/
kubectl apply -R -f /root/k8s/monitoring/
"

- name: Rolling update backend
run: |
ssh droplet 'set -e && if [ ! -d /root/portfolio/.git ]; then git clone https://github.com/dddictionary/portfolio.git /root/portfolio; fi && cd /root/portfolio && git fetch && git reset origin/main --hard && docker compose -f docker-compose.prod.yml -f docker-compose.monitoring.yml down && docker compose -f docker-compose.prod.yml up -d --build && docker compose -f docker-compose.monitoring.yml up -d --build'
IMAGE_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$(echo ${{ github.sha }} | cut -c1-7)"
ssh droplet "
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl -n portfolio set image deployment/backend backend=${IMAGE_TAG}
kubectl -n portfolio rollout status deployment/backend --timeout=300s
"
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ RUN touch src/main.rs && cargo build --release --target x86_64-unknown-linux-mus

# Stage 2: Runtime (minimal Alpine image)
FROM alpine:3.21
LABEL org.opencontainers.image.source=https://github.com/dddictionary/portfolio
RUN apk add --no-cache ca-certificates

WORKDIR /app
Expand Down
51 changes: 51 additions & 0 deletions k8s/backend/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: portfolio
labels:
app: backend
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: ghcr.io/dddictionary/portfolio:latest
ports:
- containerPort: 5000
envFrom:
- secretRef:
name: backend-secrets
readinessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"
15 changes: 15 additions & 0 deletions k8s/backend/service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: portfolio
labels:
app: backend
spec:
type: ClusterIP
ports:
- port: 5000
targetPort: 5000
protocol: TCP
selector:
app: backend
14 changes: 14 additions & 0 deletions k8s/ingress/cert-issuer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ${CERTBOT_EMAIL}
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: traefik
24 changes: 24 additions & 0 deletions k8s/ingress/ingress.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: portfolio-ingress
namespace: portfolio
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.middlewares: portfolio-rate-limit@kubernetescrd
spec:
tls:
- hosts:
- 4brar.me
secretName: portfolio-tls
rules:
- host: 4brar.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backend
port:
number: 5000
13 changes: 13 additions & 0 deletions k8s/ingress/middleware.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: rate-limit
namespace: portfolio
spec:
rateLimit:
average: 4
period: 1m
burst: 2
sourceCriterion:
ipStrategy:
depth: 1
15 changes: 15 additions & 0 deletions k8s/mariadb/service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: mariadb
namespace: portfolio
labels:
app: mariadb
spec:
clusterIP: None
ports:
- port: 3306
targetPort: 3306
protocol: TCP
selector:
app: mariadb
54 changes: 54 additions & 0 deletions k8s/mariadb/statefulset.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mariadb
namespace: portfolio
labels:
app: mariadb
spec:
serviceName: mariadb
replicas: 1
selector:
matchLabels:
app: mariadb
template:
metadata:
labels:
app: mariadb
spec:
containers:
- name: mariadb
image: mariadb:latest
ports:
- containerPort: 3306
envFrom:
- secretRef:
name: mariadb-secrets
volumeMounts:
- name: mariadb-data
mountPath: /var/lib/mysql
readinessProbe:
exec:
command:
- healthcheck.sh
- --connect
- --innodb_initialized
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumeClaimTemplates:
- metadata:
name: mariadb-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-path
resources:
requests:
storage: 5Gi
Loading