← All guides

Deploy a static site to your VPS with Docker and GitHub Actions

DevOps 2026-06-05

You do not need a platform subscription to get push-to-deploy. With a $5 VPS, Docker and GitHub Actions you get the same workflow, fully under your control. This is the exact setup behind veasy.vn.

What you need

  • A VPS (Ubuntu 22.04+) with Docker installed
  • A GitHub repository for your site
  • SSH key access to the VPS

1. Package the site with nginx

A multi-stage Dockerfile keeps the final image tiny:

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

If your site is plain HTML with no build step, skip the first stage and copy files straight into nginx.

2. Compose file on the VPS

services:
  web:
    image: ghcr.io/YOUR_USER/YOUR_REPO:latest
    restart: unless-stopped
    ports:
      - "8080:80"

Put your reverse proxy (nginx or Caddy) in front for TLS. Caddy gives you automatic HTTPS in two lines.

3. The GitHub Actions workflow

name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      packages: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
      - name: Deploy on VPS
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/site
            docker compose pull
            docker compose up -d

4. Why this beats plain rsync

  • Rollback is one command: retag or docker compose up an older image.
  • The server stays clean: no node, no build tools on the VPS, just Docker.
  • It grows with you: need an API or analytics later? Add a container to the compose file. The pipeline does not change.

Common pitfalls

  • Private GHCR images need a login on the VPS too: docker login ghcr.io with a token that has read:packages.
  • Pin your host key in CI instead of disabling strict host checking.
  • Add a smoke test step that curls your domain after deploy; a green pipeline that serves a 502 is worse than a red one.