GitHub Actions CI/CD: Driftstopp-fria deploys som faktiskt fungerar
Mitt kompletta GitHub Actions-upplägg: parallella testjobb, Docker build-cachning, SSH-deploy till VPS, driftstopp-fri deploy med PM2 reload, hemlighetshantering och workflow-mönstren jag finslipat i två år.
Varje projekt jag arbetat med når till slut samma brytpunkt: deploy-processen blir för smärtsam att göra manuellt. Du glömmer köra testerna. Du bygger lokalt men glömmer bumpa versionen. Du SSH:ar in i produktion och inser att den senaste personen som deployade lämnade en inaktuell .env-fil.
GitHub Actions löste detta för mig för två år sedan. Inte perfekt dag ett — det första workflowet jag skrev var en 200 rader lång YAML-mardröm som timeoutade hälften av gångerna och cachade ingenting. Men iteration efter iteration landade jag i något som deployar den här sajten pålitligt, med noll driftstopp, på under fyra minuter.
Det här är det workflowet, förklarat sektion för sektion. Inte docs-versionen. Versionen som överlever kontakt med produktion.
Förstå byggstenarna#
Innan vi dyker in i hela pipelinen behöver du en tydlig mental modell av hur GitHub Actions fungerar. Om du har använt Jenkins eller CircleCI, glöm det mesta du kan. Koncepten mappar löst, men exekveringsmodellen är tillräckligt annorlunda för att snubbla dig.
Triggers: När ditt workflow körs#
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Varje måndag kl. 06:00 UTC
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "staging"
type: choice
options:
- staging
- productionFyra triggers, var och en med ett eget syfte:
pushtillmainär din produktions-deploy-trigger. Kod mergad? Skeppa den.pull_requestkör dina CI-kontroller på varje PR. Det är här lint, typkontroll och tester lever.scheduleär cron för ditt repo. Jag använder det för veckovisa beroendegrankningsscans och rensning av inaktuella cacher.workflow_dispatchger dig en manuell "Deploy"-knapp i GitHub-gränssnittet med inmatningsparametrar. Ovärderligt när du behöver deploya staging utan en kodändring — kanske du uppdaterade en miljövariabel eller behöver dra om en bas-Docker-image.
En sak som biter folk: pull_request kör mot merge-committen, inte PR-grenens HEAD. Det innebär att din CI testar hur koden kommer se ut efter merge. Det är faktiskt vad du vill, men det förvånar folk när en grön gren blir röd efter en rebase.
Jobs, Steps och Runners#
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lintJobs körs parallellt som standard. Varje jobb får en ny VM ("runner"). ubuntu-latest ger dig en ganska kraftfull maskin — 4 vCPU:er, 16 GB RAM per 2026. Det är gratis för publika repon, 2000 minuter/månad för privata.
Steps körs sekventiellt inom ett jobb. Varje uses:-steg hämtar en återanvändbar action från marknadsplatsen. Varje run:-steg exekverar ett shell-kommando.
Flaggan --frozen-lockfile är avgörande. Utan den kan pnpm install uppdatera din lockfil under CI, vilket innebär att du inte testar samma beroenden som din utvecklare committade. Jag har sett detta orsaka fantomtestfel som försvinner lokalt eftersom lockfilen på utvecklarens maskin redan är korrekt.
Miljövariabler vs Hemligheter#
env:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: 1
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
echo "$SSH_PRIVATE_KEY" > key.pem
chmod 600 key.pem
ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"Miljövariabler satta med env: på workflow-nivå är klartext, synliga i loggar. Använd dessa för icke-känslig konfiguration: NODE_ENV, telemetriflaggor, funktionsväxlar.
Hemligheter (${{ secrets.X }}) är krypterade i vila, maskerade i loggar och bara tillgängliga för workflows i samma repo. De konfigureras under Settings > Secrets and variables > Actions.
Raden environment: production är betydelsefull. GitHub Environments låter dig begränsa hemligheter till specifika deploy-mål. Din staging-SSH-nyckel och din produktions-SSH-nyckel kan båda heta SSH_PRIVATE_KEY men innehålla olika värden beroende på vilken miljö jobbet riktar sig mot. Detta låser även upp obligatoriska granskare — du kan grinda produktionsdeploys bakom ett manuellt godkännande.
Den fullständiga CI-pipelinen#
Här är hur jag strukturerar CI-halvan av pipelinen. Målet: fånga varje kategori av fel på kortast möjliga tid.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm tsc --noEmit
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
build:
name: Build
needs: [lint, typecheck, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
retention-days: 1Varför denna struktur#
Lint, typkontroll och test körs parallellt. De har inga beroenden till varandra. Ett typfel blockerar inte lint från att köras, och ett misslyckat test behöver inte vänta på typkontrollen. I en typisk körning slutförs alla tre på 30-60 sekunder medan de körs samtidigt.
Build väntar på alla tre. Raden needs: [lint, typecheck, test] innebär att build-jobbet bara startar om lint, typkontroll OCH test alla passerar. Det finns ingen anledning att bygga ett projekt som har lintfel eller typfel.
concurrency med cancel-in-progress: true är en enorm tidsbesparare. Om du pushar två commits i snabb följd avbryts den första CI-körningen. Utan detta har du inaktuella körningar som förbrukar din minutbudget och belamrar kontroll-gränssnittet.
Coverage-uppladdning med if: always() innebär att du får coverage-rapporten även när tester misslyckas. Detta är användbart för felsökning — du kan se vilka tester som misslyckades och vad de täckte.
Fail-Fast vs. Låt alla köra#
Som standard, om ett jobb i en matris misslyckas, avbryter GitHub de andra. För CI vill jag faktiskt ha det beteendet — om lint misslyckas bryr jag mig inte om testresultaten. Fixa linten först.
Men för testmatriser (säg, testning över Node 20 och Node 22) kanske du vill se alla fel på en gång:
test:
strategy:
fail-fast: false
matrix:
node-version: [20, 22]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm testfail-fast: false låter båda matris-benen slutföras. Om Node 22 misslyckas men Node 20 passerar ser du den informationen omedelbart istället för att behöva köra om.
Cachning för hastighet#
Den enskilt största förbättringen du kan göra för CI-hastighet är cachning. En kall pnpm install på ett medelstort projekt tar 30-45 sekunder. Med en varm cache tar det 3-5 sekunder. Multiplicera det över fyra parallella jobb och du sparar två minuter på varje körning.
pnpm Store-cache#
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"Denna enradare cachar pnpm-storen (~/.local/share/pnpm/store). Vid cacheträff gör pnpm install --frozen-lockfile bara hårdlänkar från storen istället för att ladda ner. Enbart detta minskar installationstiden med 80% vid upprepade körningar.
Om du behöver mer kontroll — säg att du vill cacha baserat på OS också — använd actions/cache direkt:
- uses: actions/cache@v4
with:
path: |
~/.local/share/pnpm/store
node_modules
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-Reservnyckeln restore-keys är viktig. Om pnpm-lock.yaml ändras (nytt beroende) matchar inte den exakta nyckeln, men prefixmatchningen återställer fortfarande de flesta av de cachade paketen. Bara skillnaden laddas ner.
Next.js Build-cache#
Next.js har sin egen build-cache i .next/cache. Att cacha denna mellan körningar innebär inkrementella byggen — bara ändrade sidor och komponenter kompileras om.
- uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-Denna nyckelstrategi i tre nivåer innebär:
- Exakt matchning: samma beroenden OCH samma källfiler. Full cacheträff, bygget är nästan omedelbart.
- Partiell matchning (beroenden): beroenden samma men källkod ändrad. Bygget kompilerar bara om ändrade filer.
- Partiell matchning (bara OS): beroenden ändrade. Bygget återanvänder vad det kan.
Verkliga siffror från mitt projekt: kallt bygge tar ~55 sekunder, cachat bygge tar ~15 sekunder. Det är en 73% minskning.
Docker Layer-cachning#
Docker-byggen är där cachning verkligen gör skillnad. Ett fullständigt Next.js Docker-bygge — installera OS-beroenden, kopiera källkod, köra pnpm install, köra next build — tar 3-4 minuter kallt. Med lagercachning är det 30-60 sekunder.
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha använder GitHub Actions inbyggda cache-backend. mode=max cachar alla lager, inte bara de slutliga. Detta är kritiskt för flerstegsbyggen där mellanliggande lager (som pnpm install) är de dyraste att bygga om.
Turborepo Remote-cache#
Om du är i en monorepo med Turborepo är fjärrcachning transformativt. Första bygget laddar upp task-utdata till cachen. Efterföljande byggen laddar ner istället för att beräkna om.
- run: pnpm turbo build --remote-only
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Jag har sett monorepo-CI-tider sjunka från 8 minuter till 90 sekunder med Turbo remote-cache. Haken: det kräver ett Vercel-konto eller en självhostad Turbo-server. För enstaka app-repon är det överkill.
Docker-bygge och push#
Om du deployar till en VPS (eller vilken server som helst) ger Docker dig reproducerbara byggen. Samma image som körs i CI är samma image som körs i produktion. Inget mer "det fungerar på min maskin" eftersom maskinen är imagen.
Multi-stage Dockerfile#
Innan vi kommer till workflowet, här är Dockerfile jag använder för Next.js:
# Steg 1: Beroenden
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
# Steg 2: Bygge
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Steg 3: Produktion
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]Tre steg, tydlig separation. Den slutliga imagen är ~150 MB istället för ~1,2 GB som du skulle få om du kopierade allt. Bara produktionsartefakter tar sig till runner-steget.
Build-and-push-workflowet#
name: Build and Push Docker Image
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
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=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxLåt mig packa upp de viktiga besluten här.
GitHub Container Registry (ghcr.io)#
Jag använder ghcr.io istället för Docker Hub av tre anledningar:
- Autentisering är gratis.
GITHUB_TOKENär automatiskt tillgängligt i varje workflow — inget behov av att lagra Docker Hub-uppgifter. - Närhet. Images hämtas från samma infrastruktur som din CI körs på. Pulls under CI är snabba.
- Synlighet. Images är länkade till ditt repo i GitHub-gränssnittet. Du ser dem i Packages-fliken.
Multi-plattformsbyggen#
platforms: linux/amd64,linux/arm64Denna rad lägger till kanske 90 sekunder till ditt bygge, men det är värt det. ARM64-images körs nativt på:
- Apple Silicon Mac-datorer (M1/M2/M3/M4) under lokal utveckling med Docker Desktop
- AWS Graviton-instanser (20-40% billigare än x86-motsvarigheter)
- Oracle Clouds gratistier för ARM
Utan detta kör dina utvecklare på M-serie Mac-datorer x86-images genom Rosetta-emulering. Det fungerar, men det är märkbart långsammare och ger ibland konstiga arkitekturspecifika buggar.
QEMU tillhandahåller korskompileringslagret. Buildx orkestrerar det multi-arkitektoniska bygget och pushar en manifestlista så att Docker automatiskt hämtar rätt arkitektur.
Taggningsstrategi#
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}Varje image får tre taggar:
abc1234(commit-SHA): Oföränderlig. Du kan alltid deploya en exakt commit.main(grennamn): Föränderlig. Pekar på det senaste bygget från den grenen.latest: Föränderlig. Sätts bara på standardgrenen. Detta är vad din server hämtar.
Deploya aldrig latest i produktion utan att också registrera SHA:n någonstans. När något går sönder behöver du veta vilken latest. Jag lagrar den deployade SHA:n i en fil på servern som hälsokontrollens endpoint läser.
SSH-deploy till VPS#
Det är här allt kommer samman. CI passerar, Docker-image är byggd och pushad, nu behöver vi tala om för servern att hämta den nya imagen och starta om.
SSH-actionen#
deploy:
name: Deploy to Production
needs: [build-and-push]
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
script: |
set -euo pipefail
APP_DIR="/var/www/akousa.net"
IMAGE="ghcr.io/${{ github.repository }}:latest"
DEPLOY_SHA="${{ github.sha }}"
echo "=== Deployar $DEPLOY_SHA ==="
# Hämta senaste imagen
docker pull "$IMAGE"
# Stoppa och ta bort gammal container
docker stop akousa-app || true
docker rm akousa-app || true
# Starta ny container
docker run -d \
--name akousa-app \
--restart unless-stopped \
--network host \
-e NODE_ENV=production \
-e DATABASE_URL="${DATABASE_URL}" \
-p 3000:3000 \
"$IMAGE"
# Vänta på hälsokontroll
echo "Väntar på hälsokontroll..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
echo "Hälsokontroll godkänd vid försök $i"
break
fi
if [ "$i" -eq 30 ]; then
echo "Hälsokontroll misslyckades efter 30 försök"
exit 1
fi
sleep 2
done
# Registrera deployad SHA
echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
# Rensa gamla images
docker image prune -af --filter "until=168h"
echo "=== Deploy klar ==="Alternativet med deploy-skript#
För allt bortom en enkel pull-och-starta-om flyttar jag logiken till ett skript på servern istället för att ha det inline i workflowet:
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
log "Startar deploy..."
# Logga in på GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
# Hämta med omförsök
for attempt in 1 2 3; do
if docker pull "$IMAGE"; then
log "Image hämtad framgångsrikt vid försök $attempt"
break
fi
if [ "$attempt" -eq 3 ]; then
log "FEL: Misslyckades hämta image efter 3 försök"
exit 1
fi
log "Hämtningsförsök $attempt misslyckades, försöker igen om 5s..."
sleep 5
done
# Hälsokontrollfunktion
health_check() {
local port=$1
local max_attempts=30
for i in $(seq 1 $max_attempts); do
if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
# Starta ny container på alternativ port
docker run -d \
--name akousa-app-new \
--env-file "$APP_DIR/.env.production" \
-p 3001:3000 \
"$IMAGE"
# Verifiera att ny container är frisk
if ! health_check 3001; then
log "FEL: Ny container misslyckades med hälsokontroll. Rullar tillbaka."
docker stop akousa-app-new || true
docker rm akousa-app-new || true
exit 1
fi
log "Ny container frisk. Byter trafik..."
# Byt Nginx upstream
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Stoppa gammal container
docker stop akousa-app || true
docker rm akousa-app || true
# Byt namn på ny container
docker rename akousa-app-new akousa-app
log "Deploy klar."Workflowet blir då ett enda SSH-kommando:
script: |
cd /var/www/akousa.net && ./deploy.shDetta är bättre eftersom: (1) deploy-logiken är versionskontrollerad på servern, (2) du kan köra det manuellt via SSH för felsökning, och (3) du slipper escapa YAML inuti YAML inuti bash.
Strategier för noll driftstopp#
"Noll driftstopp" låter som marknadsföringsspråk, men det har en exakt betydelse: ingen begäran får ett connection refused eller en 502 under deploy. Här är tre verkliga tillvägagångssätt, från enklast till mest robust.
Strategi 1: PM2 Cluster Mode Reload#
Om du kör Node.js direkt (inte i Docker) ger PM2:s klusterläge den enklaste vägen till noll driftstopp.
# ecosystem.config.js har redan:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 reload (inte restart) gör en rullande omstart. Den spinner upp nya workers, väntar tills de är redo, och dödar sedan gamla workers en i taget. Vid ingen tidpunkt servar noll workers trafik.
Flaggan --update-env laddar om miljövariabler från ekosystemkonfigurationen. Utan den kvarstår din gamla env även efter en deploy som ändrade .env.
I ditt workflow:
- name: Deploy and reload PM2
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/akousa.net
git pull origin main
pnpm install --frozen-lockfile
pnpm build
pm2 reload ecosystem.config.js --update-envDetta är vad jag använder för den här sajten. Det är enkelt, pålitligt, och driftstoppet är bokstavligen noll — jag har testat det med en lastgenerator som körde 100 req/s under deploys. Inte en enda 5xx.
Strategi 2: Blue/Green med Nginx Upstream#
För Docker-deploys ger blue/green en ren separation mellan den gamla och nya versionen.
Konceptet: kör den gamla containern ("blue") på port 3000 och den nya containern ("green") på port 3001. Nginx pekar på blue. Du startar green, verifierar att den är frisk, byter Nginx till green, och stoppar sedan blue.
Nginx upstream-konfiguration:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /etc/nginx/sites-available/akousa.net
server {
listen 443 ssl http2;
server_name akousa.net;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}Byteskriptet:
#!/bin/bash
set -euo pipefail
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
if [ "$CURRENT_PORT" = "3000" ]; then
NEW_PORT=3001
OLD_PORT=3000
else
NEW_PORT=3000
OLD_PORT=3001
fi
echo "Aktuell: $OLD_PORT -> Ny: $NEW_PORT"
# Starta ny container på alternativ port
docker run -d \
--name "akousa-app-$NEW_PORT" \
--env-file /var/www/akousa.net/.env.production \
-p "$NEW_PORT:3000" \
"ghcr.io/akousa/akousa-net:latest"
# Vänta på hälsa
for i in $(seq 1 30); do
if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
echo "Ny container frisk på port $NEW_PORT"
break
fi
[ "$i" -eq 30 ] && { echo "Hälsokontroll misslyckades"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
sleep 2
done
# Byt Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Stoppa gammal container
sleep 5 # Låt pågående begäranden slutföras
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
echo "Bytt från :$OLD_PORT till :$NEW_PORT"5 sekunders paus efter Nginx-omladdningen är inte lathet — det är respittid. Nginx omladdning är graceful (befintliga anslutningar hålls öppna), men vissa long-polling-anslutningar eller strömande svar behöver tid att slutföras.
Strategi 3: Docker Compose med hälsokontroller#
För ett mer strukturerat tillvägagångssätt kan Docker Compose hantera blue/green-bytet:
# docker-compose.yml
services:
app:
image: ghcr.io/akousa/akousa-net:latest
restart: unless-stopped
env_file: .env.production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
rollback_config:
parallelism: 0
order: stop-first
ports:
- "3000:3000"Raden order: start-first är nyckeln. Den innebär "starta den nya containern innan den gamla stoppas." Kombinerat med parallelism: 1 får du en rullande uppdatering — en container i taget, alltid med bibehållen kapacitet.
Deploya med:
docker compose pull
docker compose up -d --remove-orphansDocker Compose övervakar hälsokontrollen och routar inte trafik till den nya containern förrän den passerar. Om hälsokontrollen misslyckas återgår failure_action: rollback automatiskt till föregående version. Detta är så nära Kubernetes-liknande rullande deploys du kan komma på en enskild VPS.
Hemlighetshantering#
Hemlighetshantering är en av de saker som är lätt att få "mestadels rätt" och katastrofalt fel i de återstående kantfallen.
GitHub Secrets: Grunderna#
# Konfigureras via GitHub-gränssnittet: Settings > Secrets and variables > Actions
steps:
- name: Use a secret
env:
DB_URL: ${{ secrets.DATABASE_URL }}
run: |
# Värdet maskeras i loggar
echo "Ansluter till databas..."
# Detta skulle skriva ut "Ansluter till ***" i loggarna
echo "Ansluter till $DB_URL"GitHub maskerar automatiskt hemlighetsvärden från loggutdata. Om din hemlighet är p@ssw0rd123 och något steg skriver ut den strängen visar loggarna ***. Detta fungerar bra, med en brasklapp: om din hemlighet är kort (som en 4-siffrig PIN) kanske GitHub inte maskerar den eftersom den kan matcha oskyldiga strängar. Håll hemligheter rimligt komplexa.
Miljöbegränsade hemligheter#
jobs:
deploy-staging:
environment: staging
steps:
- run: echo "Deployar till ${{ secrets.DEPLOY_HOST }}"
# DEPLOY_HOST = staging.akousa.net
deploy-production:
environment: production
steps:
- run: echo "Deployar till ${{ secrets.DEPLOY_HOST }}"
# DEPLOY_HOST = akousa.netSamma hemlighetsnamn, olika värden per miljö. Fältet environment på jobbet avgör vilken uppsättning hemligheter som injiceras.
Produktionsmiljöer bör ha obligatoriska granskare aktiverade. Det innebär att en push till main triggar workflowet, CI körs automatiskt, men deploy-jobbet pausar och väntar på att någon klickar "Approve" i GitHub-gränssnittet. För ett soloprojekt kan detta kännas som overhead. För allt med användare är det en livräddare första gången du av misstag mergar något trasigt.
OIDC: Inga fler statiska uppgifter#
Statiska uppgifter (AWS-åtkomstnycklar, GCP-tjänstekonto-JSON-filer) lagrade i GitHub Secrets är en belastning. De löper inte ut, de kan inte begränsas till en specifik workflow-körning, och om de läcker måste du rotera dem manuellt.
OIDC (OpenID Connect) löser detta. GitHub Actions agerar som identitetsleverantör, och din molnleverantör litar på den att utfärda kortlivade uppgifter löpande:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Krävs för OIDC
contents: read
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-central-1
- name: Push to ECR
run: |
aws ecr get-login-password --region eu-central-1 | \
docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.comIngen åtkomstnyckel. Ingen hemlig nyckel. configure-aws-credentials-actionen begär en temporär token från AWS STS med GitHubs OIDC-token. Tokenen är begränsad till det specifika repot, grenen och miljön. Den upphör efter workflow-körningen.
Att konfigurera detta på AWS-sidan kräver en IAM OIDC-identitetsleverantör och en roll-trustpolicy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
}
}
}
]
}Villkoret sub är avgörande. Utan det kunde vilket repo som helst som på något sätt får tag på din OIDC-leverantörs detaljer anta rollen. Med det kan bara main-grenen i ditt specifika repo göra det.
GCP har en motsvarande uppsättning med Workload Identity Federation. Azure har federerade uppgifter. Om ditt moln stöder OIDC, använd det. Det finns ingen anledning att lagra statiska molnuppgifter 2026.
Deploy SSH-nycklar#
För VPS-deploys via SSH, generera ett dedikerat nyckelpar:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""Lägg till den publika nyckeln i serverns ~/.ssh/authorized_keys med restriktioner:
restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy
Prefixet restrict inaktiverar portvidarebefordran, agentvidarebefordran, PTY-allokering och X11-vidarebefordran. Prefixet command= innebär att denna nyckel bara kan köra deploy-skriptet. Även om den privata nyckeln komprometteras kan angriparen köra ditt deploy-skript och inget annat.
Lägg till den privata nyckeln i GitHub Secrets som SSH_PRIVATE_KEY. Detta är den enda statiska uppgiften jag accepterar — SSH-nycklar med tvingade kommandon har en mycket begränsad skaderadie.
PR-workflows: Förhandsgranskningsmiljöer#
Varje PR förtjänar en förhandsgranskningsmiljö. Den fångar visuella buggar som enhetstester missar, låter designers granska utan att checka ut kod, och gör QA:s liv dramatiskt enklare.
Deploya en förhandsgranskning vid PR-öppning#
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
runs-on: ubuntu-latest
environment:
name: preview-${{ github.event.number }}
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Build preview image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy preview
id: deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
PORT=$((4000 + PR_NUM))
IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
docker pull "$IMAGE"
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker run -d \
--name "preview-${PR_NUM}" \
--restart unless-stopped \
-e NODE_ENV=preview \
-p "${PORT}:3000" \
"$IMAGE"
echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
- name: Comment PR with preview URL
uses: actions/github-script@v7
with:
script: |
const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
const body = `### Förhandsgranskning
| Status | URL |
|--------|-----|
| :white_check_mark: Deployad | [${url}](${url}) |
_Senast uppdaterad: ${new Date().toISOString()}_
_Commit: \`${{ github.sha }}\`_`;
// Hitta befintlig kommentar
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.user.type === 'Bot' && c.body.includes('Förhandsgranskning')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}Portberäkningen (4000 + PR_NUM) är ett pragmatiskt hack. PR #42 får port 4042. Så länge du inte har mer än några hundra öppna PR:er finns det inga kollisioner. En Nginx wildcard-konfiguration routar pr-*.preview.akousa.net till rätt port.
Rensning vid PR-stängning#
Förhandsgranskningsmiljöer som inte rensas äter disk och minne. Lägg till ett rensningsjobb:
name: Cleanup Preview
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Remove preview container
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
echo "Förhandsgranskning för PR #${PR_NUM} rensad."
- name: Deactivate environment
uses: actions/github-script@v7
with:
script: |
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-${{ github.event.number }}`,
});
for (const deployment of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
});
}Obligatoriska statuskontroller#
I dina repoinställningar (Settings > Branches > Branch protection rules), kräv dessa kontroller innan merge:
lint— Inga lintfeltypecheck— Inga typfeltest— Alla tester passerarbuild— Projektet bygger framgångsrikt
Utan detta kommer någon att merga en PR med misslyckade kontroller. Inte avsiktligt — de ser "2 av 4 kontroller godkända" och antar att de andra två fortfarande körs. Lås ner det.
Aktivera också "Require branches to be up to date before merging." Detta tvingar fram en omkörning av CI efter rebase på senaste main. Det fångar fallet där två PR:er individuellt passerar CI men konfliktar när de kombineras.
Notifieringar#
En deploy som ingen vet om är en deploy som ingen litar på. Notifieringar stänger feedbackloopen.
Slack Webhook#
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Repository:*\n${{ github.repository }}"
},
{
"type": "mrkdwn",
"text": "*Branch:*\n${{ github.ref_name }}"
},
{
"type": "mrkdwn",
"text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
},
{
"type": "mrkdwn",
"text": "*Triggered by:*\n${{ github.actor }}"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View Run"
},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}if: always() är kritiskt. Utan det hoppas notifieringssteget över när deployen misslyckas — vilket är exakt när du behöver det mest.
GitHub Deployments API#
För rikare deploy-spårning, använd GitHub Deployments API. Detta ger dig en deploy-historik i repo-gränssnittet och möjliggör statusmärken:
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@v7
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.sha,
environment: 'production',
auto_merge: false,
required_contexts: [],
description: `Deployar ${context.sha.substring(0, 7)} till produktion`,
});
return deployment.data.id;
- name: Deploy
run: |
# ... faktiska deploysteg ...
- name: Update deployment status
if: always()
uses: actions/github-script@v7
with:
script: |
const deploymentId = ${{ steps.deployment.outputs.result }};
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deploymentId,
state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
environment_url: 'https://akousa.net',
log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
description: '${{ job.status }}' === 'success'
? 'Deploy lyckades'
: 'Deploy misslyckades',
});Nu visar din Environments-flik i GitHub en komplett deploy-historik: vem som deployade vad, när, och om det lyckades.
E-post vid misslyckande#
För kritiska deploys triggar jag även ett e-postmeddelande vid misslyckande. Inte via GitHub Actions inbyggda e-post (för brusigt), utan via en riktad webhook:
- name: Alert on failure
if: failure()
run: |
curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d '{
"subject": "DEPLOY MISSLYCKADES: ${{ github.repository }}",
"body": "Commit: ${{ github.sha }}\nAktör: ${{ github.actor }}\nKörning: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}'Detta är min sista försvarslinje. Slack är bra men det är också brusigt — folk mutar kanaler. Ett "DEPLOY MISSLYCKADES"-mejl med en länk till körningen fångar uppmärksamhet.
Den kompletta workflow-filen#
Här är allt sammanfogar till ett enda, produktionsklart workflow. Detta ligger mycket nära det som faktiskt deployar den här sajten.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
skip_tests:
description: "Skip tests (emergency deploy)"
required: false
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
NODE_VERSION: "22"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ============================================================
# CI: Lint, typkontroll och test parallellt
# ============================================================
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ESLint
run: pnpm lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run TypeScript compiler
run: pnpm tsc --noEmit
test:
name: Unit Tests
if: ${{ !inputs.skip_tests }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests with coverage
run: pnpm test -- --coverage
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
# ============================================================
# Bygge: Bara efter CI passerar
# ============================================================
build:
name: Build Application
needs: [lint, typecheck, test]
if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-
- name: Build Next.js application
run: pnpm build
# ============================================================
# Docker: Bygg och pusha image (bara main-grenen)
# ============================================================
docker:
name: Build Docker Image
needs: [build]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU for multi-platform builds
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ============================================================
# Deploy: SSH in i VPS och uppdatera
# ============================================================
deploy:
name: Deploy to Production
needs: [docker]
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
environment:
name: production
url: https://akousa.net
steps:
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@v7
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.sha,
environment: 'production',
auto_merge: false,
required_contexts: [],
description: `Deploy ${context.sha.substring(0, 7)}`,
});
return deployment.data.id;
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
command_timeout: 5m
script: |
set -euo pipefail
APP_DIR="/var/www/akousa.net"
IMAGE="ghcr.io/${{ github.repository }}:latest"
SHA="${{ github.sha }}"
echo "=== Deploy $SHA startad $(date) ==="
# Hämta ny image
docker pull "$IMAGE"
# Kör ny container på alternativ port
docker run -d \
--name akousa-app-new \
--env-file "$APP_DIR/.env.production" \
-p 3001:3000 \
"$IMAGE"
# Hälsokontroll
echo "Kör hälsokontroll..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
echo "Hälsokontroll godkänd (försök $i)"
break
fi
if [ "$i" -eq 30 ]; then
echo "FEL: Hälsokontroll misslyckades"
docker logs akousa-app-new --tail 50
docker stop akousa-app-new && docker rm akousa-app-new
exit 1
fi
sleep 2
done
# Byt trafik
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Respittid för pågående begäranden
sleep 5
# Stoppa gammal container
docker stop akousa-app || true
docker rm akousa-app || true
# Byt namn och återställ port
docker rename akousa-app-new akousa-app
sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
# Obs: vi laddar inte om Nginx här eftersom containernamnet ändrades,
# inte porten. Nästa deploy kommer använda korrekt port.
# Registrera deploy
echo "$SHA" > "$APP_DIR/.deployed-sha"
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
# Rensa gamla images (äldre än 7 dagar)
docker image prune -af --filter "until=168h"
echo "=== Deploy klar $(date) ==="
- name: Update deployment status
if: always()
uses: actions/github-script@v7
with:
script: |
const deploymentId = ${{ steps.deployment.outputs.result }};
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deploymentId,
state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
environment_url: 'https://akousa.net',
log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
});
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
},
{
"type": "mrkdwn",
"text": "*Actor:*\n${{ github.actor }}"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View Run" },
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}
- name: Alert on failure
if: failure()
run: |
curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d '{
"subject": "DEPLOY MISSLYCKADES: ${{ github.repository }}",
"body": "Commit: ${{ github.sha }}\nKörning: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}' || trueGenomgång av flödet#
När jag pushar till main:
- Lint, Typkontroll och Test startar samtidigt. Tre runners, tre parallella jobb. Om något misslyckas stannar pipelinen.
- Bygge körs bara om alla tre passerar. Det validerar att applikationen kompilerar och producerar fungerande utdata.
- Docker bygger produktionsimagen och pushar den till ghcr.io. Multi-plattform, lagercachad.
- Deploy SSH:ar in i VPS:en, hämtar den nya imagen, startar en ny container, hälsokontrollerar den, byter Nginx och rensar upp.
- Notifieringar avfyras oavsett utfall. Slack får meddelandet. GitHub Deployments uppdateras. Om det misslyckades skickas ett varningsmeddelande.
När jag öppnar en PR:
- Lint, Typkontroll och Test körs. Samma kvalitetsgrindar.
- Bygge körs för att verifiera att projektet kompilerar.
- Docker och Deploy hoppas över (
if-villkoren begränsar dem till baramain-grenen).
När jag behöver en nöddeploy (hoppa över tester):
- Klicka "Run workflow" i Actions-fliken.
- Välj
skip_tests: true. - Lint och typkontroll körs fortfarande (du kan inte hoppa över dem — jag litar inte på mig själv så mycket).
- Tester hoppas över, bygge körs, Docker bygger, deploy avfyras.
Detta har varit mitt workflow i två år. Det har överlevt servermigrationer, Node.js major version-uppgraderingar, pnpm som ersatte npm, och tillägg av 15 verktyg till den här sajten. Den totala end-to-end-tiden från push till produktion: 3 minuter och 40 sekunder i genomsnitt. Det långsammaste steget är Docker-bygget för multi-plattform på ~90 sekunder. Allt annat är cachat till nästan omedelbart.
Lärdomar från två års iteration#
Jag avslutar med de misstag jag gjort så att du slipper göra dem.
Pinnar dina action-versioner. uses: actions/checkout@v4 är okej, men för produktion, överväg uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (hela SHA:n). En komprometterad action kan exfiltrera dina hemligheter. Incidenten med tj-actions/changed-files 2025 bevisade att detta inte är teoretiskt.
Cacha inte allt. Jag cachade en gång node_modules direkt (inte bara pnpm-storen) och ägnade två timmar åt att felsöka ett fantombyggfel orsakat av inaktuella native bindings. Cacha pakethanterarens store, inte de installerade modulerna.
Sätt timeouts. Varje jobb bör ha timeout-minutes. Standardvärdet är 360 minuter (6 timmar). Om din deploy hänger sig för att SSH-anslutningen tappades vill du inte upptäcka det sex timmar senare när du har bränt igenom din månatliga minutbudget.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestAnvänd concurrency klokt. För PR:er är cancel-in-progress: true alltid rätt — ingen bryr sig om CI-resultatet av en commit som redan har force-pushats över. För produktionsdeploys, sätt det till false. Du vill inte att en snabb uppföljningscommit ska avbryta en deploy som är mitt i utrullningen.
Testa din workflow-fil. Använd act (https://github.com/nektos/act) för att köra workflows lokalt. Det fångar inte allt (hemligheter är inte tillgängliga, och runner-miljön skiljer sig), men det fångar YAML-syntaxfel och uppenbara logikbuggar innan du pushar.
Övervaka dina CI-kostnader. GitHub Actions-minuter är gratis för publika repon och billiga för privata, men de ackumuleras. Multi-plattforms Docker-byggen tar 2x minuterna (ett per plattform). Matris-teststrategier multiplicerar din körtid. Håll koll på faktureringssidan.
Den bästa CI/CD-pipelinen är den du litar på. Förtroende kommer från pålitlighet, observerbarhet och inkrementell förbättring. Börja med en enkel lint-test-build-pipeline. Lägg till Docker när du behöver reproducerbarhet. Lägg till SSH-deploy när du behöver automation. Lägg till notifieringar när du behöver trygghet. Bygg inte hela pipelinen dag ett — du kommer få abstraktionerna fel.