Gå till innehåll
·29 min läsning

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.

Dela:X / TwitterLinkedIn

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#

yaml
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
          - production

Fyra triggers, var och en med ett eget syfte:

  • push till main är din produktions-deploy-trigger. Kod mergad? Skeppa den.
  • pull_request kö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_dispatch ger 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#

yaml
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 lint

Jobs 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#

yaml
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.

yaml
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: 1

Varfö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:

yaml
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 test

fail-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#

yaml
- 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:

yaml
- 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.

yaml
- 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:

  1. Exakt matchning: samma beroenden OCH samma källfiler. Full cacheträff, bygget är nästan omedelbart.
  2. Partiell matchning (beroenden): beroenden samma men källkod ändrad. Bygget kompilerar bara om ändrade filer.
  3. 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.

yaml
- 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=max

type=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.

yaml
- 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:

dockerfile
# 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#

yaml
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=max

Lå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:

  1. Autentisering är gratis. GITHUB_TOKEN är automatiskt tillgängligt i varje workflow — inget behov av att lagra Docker Hub-uppgifter.
  2. Närhet. Images hämtas från samma infrastruktur som din CI körs på. Pulls under CI är snabba.
  3. Synlighet. Images är länkade till ditt repo i GitHub-gränssnittet. Du ser dem i Packages-fliken.

Multi-plattformsbyggen#

yaml
platforms: linux/amd64,linux/arm64

Denna 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#

yaml
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#

yaml
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:

bash
#!/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:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

Detta ä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.

bash
# ecosystem.config.js har redan:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 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:

yaml
- 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-env

Detta ä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:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /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:

bash
#!/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:

yaml
# 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:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker 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#

yaml
# 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#

yaml
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.net

Samma 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:

yaml
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.com

Ingen å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:

json
{
  "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:

bash
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#

yaml
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:

yaml
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 lintfel
  • typecheck — Inga typfel
  • test — Alla tester passerar
  • build — 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#

yaml
- 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:

yaml
- 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:

yaml
- 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.

yaml
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 }}"
            }' || true

Genomgång av flödet#

När jag pushar till main:

  1. Lint, Typkontroll och Test startar samtidigt. Tre runners, tre parallella jobb. Om något misslyckas stannar pipelinen.
  2. Bygge körs bara om alla tre passerar. Det validerar att applikationen kompilerar och producerar fungerande utdata.
  3. Docker bygger produktionsimagen och pushar den till ghcr.io. Multi-plattform, lagercachad.
  4. Deploy SSH:ar in i VPS:en, hämtar den nya imagen, startar en ny container, hälsokontrollerar den, byter Nginx och rensar upp.
  5. Notifieringar avfyras oavsett utfall. Slack får meddelandet. GitHub Deployments uppdateras. Om det misslyckades skickas ett varningsmeddelande.

När jag öppnar en PR:

  1. Lint, Typkontroll och Test körs. Samma kvalitetsgrindar.
  2. Bygge körs för att verifiera att projektet kompilerar.
  3. Docker och Deploy hoppas över (if-villkoren begränsar dem till bara main-grenen).

När jag behöver en nöddeploy (hoppa över tester):

  1. Klicka "Run workflow" i Actions-fliken.
  2. Välj skip_tests: true.
  3. Lint och typkontroll körs fortfarande (du kan inte hoppa över dem — jag litar inte på mig själv så mycket).
  4. 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.

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

Anvä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.

Relaterade inlägg