#GitHub Actions: Deploy แอปลง DigitalOcean ด้วย Docker Compose ผ่าน SSH อย่างปลอดภัย

การ deploy แอปพลิเคชันที่รันด้วย Docker Compose บน DigitalOcean Droplet สามารถทำให้เป็นอัตโนมัติด้วย GitHub Actions ได้ โดยเมื่อมีการ push หรือ merge โค้ดเข้าสู่ branch main ระบบ CI/CD จะทดสอบและ build Docker image จากนั้น server จะรับคำสั่งผ่าน SSH เพื่อดึง image รุ่นใหม่และ restart service ด้วย Docker Compose

บทความนี้เน้นรูปแบบที่เหมาะกับ production มากกว่าการ SSH เข้า server แล้ว git pull และ docker compose build โดยตรง กล่าวคือ:

Developer
    |
    | git push
    v
GitHub Repository
    |
    v
GitHub Actions
    |
    +--> Test
    +--> Build Docker Image
    +--> Push Image
    |
    v
Container Registry
    |
    | SSH
    v
DigitalOcean Droplet
    |
    +--> docker compose pull
    +--> docker compose up -d
    +--> Health Check

#1. ทำไมควร Build Image ใน CI

แนวทางที่ทำได้ง่ายที่สุดคือให้ GitHub Actions SSH ไปยัง Droplet แล้วสั่ง:

git pull
docker compose build
docker compose up -d

แต่ production ที่ต้องการ reproducibility และ rollback ที่ชัดเจน ควร build image ใน CI แล้ว push ไปยัง registry ก่อน เช่น GitHub Container Registry (GHCR)

ข้อดีคือ:

  • Production server ไม่จำเป็นต้องมี source code ทั้งหมด
  • ไม่ต้อง compile/build application บน production
  • Docker image ที่ผ่าน CI คือ artifact เดียวกับที่นำไป deploy
  • ระบุ version ด้วย Git commit SHA ได้
  • rollback ไป image รุ่นก่อนหน้าได้ง่าย
  • ลดความแตกต่างระหว่าง build environment และ production

ตัวอย่างชื่อ image:

ghcr.io/example/myapp:8f36b9a

ควรหลีกเลี่ยงการพึ่งพา latest เพียงอย่างเดียว เพราะไม่สามารถระบุได้ชัดเจนว่า production กำลังรัน source revision ใด


#2. เตรียม DigitalOcean Droplet

ตัวอย่างนี้สมมติว่าใช้ Ubuntu Server และมี Docker Engine กับ Docker Compose plugin แล้ว

ตรวจสอบ:

docker --version
docker compose version

สร้าง user สำหรับ deployment แยกจาก root เช่น:

sudo adduser deploy

หาก deployment user ต้องสั่ง Docker:

sudo usermod -aG docker deploy

จากนั้น logout/login ใหม่ แล้วทดสอบ:

docker ps

หมายเหตุด้านความปลอดภัย: สมาชิกกลุ่ม docker มีสิทธิ์สูงมากและในทางปฏิบัติสามารถยกระดับสิทธิ์บน host ได้ จึงควรให้เฉพาะบัญชีที่จำเป็นเท่านั้น

สร้าง directory:

sudo mkdir -p /opt/myapp
sudo chown -R deploy:deploy /opt/myapp

#3. สร้าง SSH Key สำหรับ Deployment

ควรแยก deploy key ออกจาก SSH key ส่วนตัวของผู้ดูแลระบบ

สร้าง key บนเครื่องที่เชื่อถือได้:

ssh-keygen -t ed25519 -C "github-actions-deploy"

ตัวอย่าง:

github-actions-deploy
github-actions-deploy.pub

นำ public key ไปเพิ่มที่ Droplet:

mkdir -p ~/.ssh
chmod 700 ~/.ssh
cat github-actions-deploy.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

ส่วน private key จะนำไปเก็บเป็น GitHub Secret

ห้าม commit private key ลง repository


#4. Harden SSH

ไฟล์:

/etc/ssh/sshd_config

ค่าที่ควรพิจารณา:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes

ตรวจสอบ configuration ก่อน restart:

sudo sshd -t

แล้วจึง:

sudo systemctl restart ssh

อย่าปิด SSH session เดิมจนกว่าจะทดสอบว่า session ใหม่ login ด้วย key ได้สำเร็จ


#5. Firewall

Production web server โดยทั่วไปควรเปิดเฉพาะ port ที่จำเป็น เช่น:

22   SSH
80   HTTP
443  HTTPS

Database เช่น PostgreSQL, MySQL หรือ Redis ไม่ควรเปิด public port หาก application ใช้งานอยู่ใน Docker network เดียวกัน

DigitalOcean Cloud Firewall เป็น stateful firewall และ traffic inbound ที่ไม่ได้อนุญาตจะถูก block ควรจำกัด SSH source ให้แคบที่สุดเท่าที่ architecture รองรับ


#6. Docker Compose สำหรับ Production

ตัวอย่าง compose.yaml:

services:
  app:
    image: ${APP_IMAGE}
    restart: unless-stopped

    environment:
      NODE_ENV: production

    ports:
      - "127.0.0.1:3000:3000"

    healthcheck:
      test:
        [
          "CMD",
          "wget",
          "--no-verbose",
          "--tries=1",
          "--spider",
          "http://localhost:3000/health"
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

    networks:
      - app-network

networks:
  app-network:
    driver: bridge

การ bind application port กับ 127.0.0.1 ทำให้ port 3000 ไม่ถูก expose ตรงสู่อินเทอร์เน็ต โดยสามารถให้ Caddy หรือ Nginx ทำ reverse proxy จาก 80/443 เข้ามาแทน


#7. Environment File บน Server

สร้าง:

/opt/myapp/.env

ตัวอย่าง:

APP_IMAGE=ghcr.io/example/myapp:8f36b9a

กำหนด permission:

chmod 600 /opt/myapp/.env

อย่า commit production .env ลง Git

เพิ่มใน .gitignore:

.env
.env.*
!.env.example

#8. Application Secrets

ไม่ควรใส่ secret ลง Dockerfile:

ENV DB_PASSWORD=my-secret-password

และไม่ควร hard-code ลง compose.yaml

สำหรับ secret ที่ application รองรับแบบ file-based สามารถใช้ Compose secrets เช่น:

services:
  app:
    image: ${APP_IMAGE}

    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

ภายใน container จะอ่านได้จาก:

/run/secrets/db_password

ควรจำกัด permission ของ secret files บน host และไม่ commit directory ดังกล่าว


#9. GitHub Secrets / Environment

ใน GitHub repository สร้าง Environment:

production

จากนั้นกำหนด secrets เช่น:

DO_HOST
DO_USER
DO_SSH_PRIVATE_KEY
DO_SSH_HOST_KEY

ตัวอย่าง:

DO_HOST = 203.0.113.10
DO_USER = deploy

DO_SSH_PRIVATE_KEY คือ private key สำหรับ deployment

DO_SSH_HOST_KEY คือ host key ของ Droplet ที่ตรวจสอบ fingerprint แล้ว เช่น:

203.0.113.10 ssh-ed25519 AAAAC3...

Environment สามารถใช้ร่วมกับ deployment protection rules เช่น required reviewers เพื่อให้ production deployment ต้องผ่าน approval ก่อน


#10. อย่าปิด SSH Host Verification

ตัวอย่างที่พบได้บ่อย:

ssh -o StrictHostKeyChecking=no deploy@$HOST

ไม่ควรใช้ใน production เพราะเป็นการลดการตรวจสอบ identity ของ server

ควรใช้:

-o StrictHostKeyChecking=yes

พร้อม known_hosts ที่เตรียมและตรวจสอบไว้ล่วงหน้า

ตัวอย่าง:

printf '%s\n' "$SSH_HOST_KEY" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts

การเรียก ssh-keyscan ระหว่าง workflow แล้วเชื่อถือผลลัพธ์ทันทีโดยไม่มีช่องทางอื่นสำหรับตรวจ fingerprint ไม่ได้ให้ assurance แบบเดียวกับการ pin host key ที่ตรวจสอบแล้ว


#11. Workflow แบบง่าย: SSH ไป Deploy

สร้างไฟล์:

.github/workflows/deploy.yml
name: Deploy Production

on:
  push:
    branches:
      - main

permissions:
  contents: read

concurrency:
  group: production
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest

    environment:
      name: production

    steps:
      - name: Configure SSH
        env:
          SSH_PRIVATE_KEY: ${{ secrets.DO_SSH_PRIVATE_KEY }}
          SSH_HOST_KEY: ${{ secrets.DO_SSH_HOST_KEY }}
        run: |
          install -m 700 -d ~/.ssh

          printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key

          printf '%s\n' "$SSH_HOST_KEY" > ~/.ssh/known_hosts
          chmod 600 ~/.ssh/known_hosts

      - name: Deploy
        env:
          HOST: ${{ secrets.DO_HOST }}
          USER: ${{ secrets.DO_USER }}
        run: |
          ssh \
            -i ~/.ssh/deploy_key \
            -o IdentitiesOnly=yes \
            -o StrictHostKeyChecking=yes \
            "$USER@$HOST" << 'EOF'

            set -euo pipefail

            cd /opt/myapp

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

            docker compose ps

          EOF

set -euo pipefail ช่วยให้ deployment script หยุดเมื่อ command ล้มเหลวหรือมีการอ้างตัวแปรที่ไม่ได้กำหนด


#12. Workflow แบบ Production: Test → Build → Push → Deploy

ตัวอย่างนี้ใช้ GHCR

name: CI/CD Production

on:
  push:
    branches:
      - main

permissions:
  contents: read
  packages: write

concurrency:
  group: production
  cancel-in-progress: false

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

jobs:

  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@<FULL_COMMIT_SHA>

      - name: Run tests
        run: |
          echo "Run your tests here"

  build:
    needs: test
    runs-on: ubuntu-latest

    outputs:
      image: ${{ steps.image.outputs.value }}

    steps:
      - name: Checkout
        uses: actions/checkout@<FULL_COMMIT_SHA>

      - name: Login to GHCR
        uses: docker/login-action@<FULL_COMMIT_SHA>
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set image name
        id: image
        run: |
          IMAGE="${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}"
          IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
          echo "value=$IMAGE" >> "$GITHUB_OUTPUT"

      - name: Build image
        env:
          IMAGE: ${{ steps.image.outputs.value }}
        run: |
          docker build -t "$IMAGE" .

      - name: Push image
        env:
          IMAGE: ${{ steps.image.outputs.value }}
        run: |
          docker push "$IMAGE"

  deploy:
    needs: build
    runs-on: ubuntu-latest

    environment:
      name: production

    permissions:
      contents: read
      packages: read

    steps:
      - name: Configure SSH
        env:
          SSH_PRIVATE_KEY: ${{ secrets.DO_SSH_PRIVATE_KEY }}
          SSH_HOST_KEY: ${{ secrets.DO_SSH_HOST_KEY }}
        run: |
          install -m 700 -d ~/.ssh

          printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key

          printf '%s\n' "$SSH_HOST_KEY" > ~/.ssh/known_hosts
          chmod 600 ~/.ssh/known_hosts

      - name: Deploy image
        env:
          HOST: ${{ secrets.DO_HOST }}
          USER: ${{ secrets.DO_USER }}
          IMAGE: ${{ needs.build.outputs.image }}
        run: |
          ssh \
            -i ~/.ssh/deploy_key \
            -o IdentitiesOnly=yes \
            -o StrictHostKeyChecking=yes \
            "$USER@$HOST" \
            "APP_IMAGE='$IMAGE' bash -s" << 'EOF'

            set -euo pipefail

            cd /opt/myapp

            printf 'APP_IMAGE=%s\n' "$APP_IMAGE" > .env.deploy

            docker compose \
              --env-file .env.deploy \
              pull

            docker compose \
              --env-file .env.deploy \
              up -d --remove-orphans

            docker compose ps

          EOF

ตัวอย่าง <FULL_COMMIT_SHA> เป็น placeholder ต้องแทนด้วย SHA ที่ตรวจสอบแล้วของ action แต่ละตัวก่อนใช้งานจริง GitHub แนะนำการ pin third-party actions ด้วย full-length commit SHA เพื่อให้ action reference เป็น immutable


#13. Authentication จาก Droplet ไป GHCR

หาก package เป็น private Droplet ต้องสามารถ authenticate กับ registry ได้

ควรใช้ credential ที่มีสิทธิ์ต่ำที่สุดที่จำเป็นสำหรับการ pull และ login บน server ตามกลไกที่ registry รองรับ

จากนั้น:

docker pull ghcr.io/example/myapp:<commit-sha>

ต้องทำงานได้ก่อนเปิด CI/CD

อย่าใส่ registry password ลง compose.yaml


#14. เพิ่ม Health Check หลัง Deployment

การที่:

docker compose up -d

สำเร็จ ไม่ได้หมายความว่า application พร้อมใช้งานจริง

ควรตรวจ endpoint เช่น:

https://example.com/health

ตัวอย่างใน workflow:

for i in {1..12}; do
  if curl --fail --silent --show-error \
    https://example.com/health > /dev/null; then

    echo "Application is healthy"
    exit 0
  fi

  echo "Waiting for application..."
  sleep 5
done

echo "Health check failed"
exit 1

ทำให้ workflow fail เมื่อ application ไม่พร้อมหลัง deploy


#15. Rollback

ก่อน deploy ควรเก็บ image version ปัจจุบัน

ตัวอย่าง:

cp .env.deploy .env.previous

จากนั้น deploy version ใหม่:

printf 'APP_IMAGE=%s\n' "$NEW_IMAGE" > .env.deploy

docker compose \
  --env-file .env.deploy \
  pull

docker compose \
  --env-file .env.deploy \
  up -d

หาก health check ไม่ผ่าน:

mv .env.previous .env.deploy

docker compose \
  --env-file .env.deploy \
  up -d

แนวคิดคือ:

v1 running
    |
    v
deploy v2
    |
    +---- health OK ----> keep v2
    |
    +---- health FAIL --> rollback v1

#16. ตัวอย่าง Deploy Script บน Server

แทนที่จะเขียน shell script ยาวใน workflow สามารถเก็บ deployment logic ฝั่ง server:

/opt/myapp/deploy.sh
#!/usr/bin/env bash

set -euo pipefail

IMAGE="${1:?Docker image is required}"

cd /opt/myapp

if [ -f .env.deploy ]; then
    cp .env.deploy .env.previous
fi

printf 'APP_IMAGE=%s\n' "$IMAGE" > .env.deploy

docker compose \
    --env-file .env.deploy \
    pull

docker compose \
    --env-file .env.deploy \
    up -d --remove-orphans

docker compose ps

permission:

chmod 750 /opt/myapp/deploy.sh

จาก GitHub Actions:

ssh deploy@$HOST \
  "/opt/myapp/deploy.sh '$IMAGE'"

ข้อดีคือ deployment logic มีจุดเดียวและ workflow อ่านง่ายขึ้น


#17. จำกัดสิทธิ์ GitHub Actions

อย่าปล่อย default permissions กว้างเกินความจำเป็น

ตัวอย่าง:

permissions:
  contents: read

หาก job ต้อง push GHCR:

permissions:
  contents: read
  packages: write

และสามารถลดสิทธิ์เฉพาะ job ได้

หลักการคือ least privilege


#18. Pin Third-Party Actions

หลีกเลี่ยงการอ้าง action ที่เปลี่ยนแปลงได้ง่ายใน workflow ที่ต้องการ security สูง เช่น:

uses: vendor/action@main

สำหรับ production GitHub แนะนำ pin ด้วย full-length commit SHA:

uses: vendor/action@0123456789abcdef0123456789abcdef01234567

และควรใช้ Dependabot หรือกระบวนการ review เพื่ออัปเดต SHA เมื่อ action มี release ใหม่


#19. GitHub Environment Protection

สร้าง Environment:

production

จากนั้นกำหนด deployment protection ตามความเหมาะสม เช่น:

main
   |
   v
CI Test
   |
   v
Build Image
   |
   v
Push Registry
   |
   v
Waiting for Approval
   |
   v
Production Deploy

วิธีนี้ลดโอกาสที่การ push โดยไม่ตั้งใจจะ deploy production ทันที


#20. Docker Security

ควรพิจารณา:

  • ใช้ base image ที่จำเป็นเท่านั้น
  • ไม่รัน application process เป็น root หากไม่จำเป็น
  • scan image vulnerability ก่อน deploy
  • ไม่ใส่ secret ใน Docker image layer
  • pin dependency versions ตามความเหมาะสม
  • ใช้ read-only filesystem สำหรับ workload ที่รองรับ
  • จำกัด Linux capabilities
  • กำหนด resource limits
  • update base images และ OS security patches สม่ำเสมอ

ตัวอย่าง Dockerfile:

FROM node:24-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

USER node

CMD ["node", "server.js"]

#21. Database Deployment

ไม่ควรทำ destructive migration โดยอัตโนมัติโดยไม่มีแผน rollback

เช่น:

docker compose exec -T app npm run migrate

ก่อนใช้ production ต้องพิจารณา:

  • migration backward-compatible หรือไม่
  • application รุ่นเก่ายังทำงานกับ schema ใหม่ได้หรือไม่
  • มี backup หรือ snapshot หรือไม่
  • rollback application แล้ว database จะยัง compatible หรือไม่

แนวทางที่เหมาะกับระบบที่ต้องการ downtime ต่ำคือ expand-and-contract migration


#22. ไม่ควรเปิด Database Port สู่ Internet

ตัวอย่าง Compose:

services:

  app:
    networks:
      - backend

  postgres:
    image: postgres:18
    networks:
      - backend

ไม่จำเป็นต้อง:

ports:
  - "5432:5432"

หากมีเพียง application container ที่ต้องเข้าถึง PostgreSQL


#23. Reverse Proxy + HTTPS

Production architecture สามารถเป็น:

Internet
    |
    | HTTPS :443
    v
DigitalOcean Firewall
    |
    v
Caddy / Nginx
    |
    | Docker Network
    v
Application
    |
    v
Database

เปิด public เฉพาะ:

80
443

ส่วน application port และ database port ให้อยู่ภายใน host/Docker network


#24. Zero/Low-Downtime

คำสั่ง:

docker compose up -d

เหมาะกับระบบขนาดเล็ก แต่ไม่ได้ให้ zero-downtime guarantee โดยตัวมันเอง

ถ้าระบบต้องการ availability สูงขึ้น สามารถใช้:

  • Blue-Green Deployment
  • Reverse proxy switching
  • Multiple application replicas
  • Load balancer
  • Kubernetes
  • DigitalOcean Kubernetes (DOKS)

สำหรับเว็บขนาดเล็กถึงกลางที่ใช้ Droplet เดียว Docker Compose + reverse proxy + health check + rollback เป็นจุดเริ่มต้นที่ดูแลง่าย


#25. Security Checklist

ก่อนใช้ production ตรวจสอบอย่างน้อย:

[ ] ใช้ SSH key แทน password
[ ] ไม่ deploy ด้วย root
[ ] ปิด root/password SSH login ตามความเหมาะสม
[ ] มี dedicated deploy key
[ ] Private key อยู่ใน GitHub Secret/Environment
[ ] ตรวจสอบและ pin SSH host key
[ ] StrictHostKeyChecking=yes
[ ] จำกัด GitHub Actions permissions
[ ] Pin third-party Actions ด้วย full commit SHA
[ ] ใช้ GitHub production Environment
[ ] ใช้ approval ก่อน production หากเหมาะสม
[ ] ไม่ commit .env
[ ] ไม่ใส่ secret ใน Dockerfile
[ ] จำกัด firewall ports
[ ] ไม่ expose database สู่ public
[ ] ใช้ immutable image tag เช่น Git SHA
[ ] มี application health check
[ ] มี rollback strategy
[ ] มี database backup
[ ] scan Docker images/dependencies
[ ] update OS และ Docker security patches
[ ] เปิด monitoring และ alerting

#26. โครงสร้าง Repository

ตัวอย่าง:

myapp/
├── .github/
│   └── workflows/
│       └── deploy.yml
│
├── src/
│
├── Dockerfile
├── compose.yaml
├── compose.production.yaml
├── .dockerignore
├── .gitignore
└── README.md

Server:

/opt/myapp/
├── compose.yaml
├── compose.production.yaml
├── .env.deploy
├── deploy.sh
└── secrets/

#27. Deployment Flow ที่แนะนำ

1. Developer pushes to main
              |
              v
2. GitHub Actions starts
              |
              v
3. Unit / Integration Tests
              |
              v
4. Security / Dependency Scan
              |
              v
5. Build Docker Image
              |
              v
6. Tag Image with Git SHA
              |
              v
7. Push to Container Registry
              |
              v
8. Production Approval
              |
              v
9. SSH to DigitalOcean
              |
              v
10. docker compose pull
              |
              v
11. docker compose up -d
              |
              v
12. Health Check
        /             \
       /               \
     PASS              FAIL
      |                  |
      v                  v
   Complete           Rollback

#สรุป

GitHub Actions + Docker Compose + DigitalOcean Droplet เป็น CI/CD architecture ที่ไม่ซับซ้อนและเหมาะกับระบบจำนวนมาก แต่จุดสำคัญไม่ใช่เพียงทำให้ workflow SSH เข้า server ได้

Production deployment ที่ดีควรออกแบบให้:

Build Once → Deploy Immutable Artifact → Verify → Rollback

แทน:

SSH → Git Pull → Build บน Production → Restart

องค์ประกอบสำคัญคือ SSH key authentication, non-root deployment user, verified SSH host key, least-privilege GitHub token, GitHub Environment, immutable Docker image tags, firewall, secrets management, health checks, backups และ rollback strategy

เมื่อระบบเติบโตขึ้น architecture นี้สามารถพัฒนาไปสู่ Blue-Green Deployment, Load Balancer, GitOps หรือ Kubernetes ได้โดยไม่ต้องเปลี่ยนหลักการพื้นฐานเรื่อง immutable artifact และ least privilege

#เอกสารอ้างอิง