guide · DevOps
Deploy a static site to your VPS with Docker and GitHub Actions
A production-grade pipeline for any static site: build in CI, package with nginx, ship to your own server with one git push.
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:
1FROM node:22-alpine AS build2WORKDIR /app3COPY package*.json ./4RUN npm ci5COPY . .6RUN npm run build78FROM nginx:alpine9COPY --from=build /app/dist /usr/share/nginx/html10EXPOSE 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
1services:2 web:3 image: ghcr.io/YOUR_USER/YOUR_REPO:latest4 restart: unless-stopped5 ports:6 - "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
1name: Deploy2on:3 push:4 branches: [main]56jobs:7 deploy:8 runs-on: ubuntu-latest9 permissions:10 packages: write11 contents: read12 steps:13 - uses: actions/checkout@v414 - uses: docker/login-action@v315 with:16 registry: ghcr.io17 username: ${{ github.actor }}18 password: ${{ secrets.GITHUB_TOKEN }}19 - uses: docker/build-push-action@v620 with:21 context: .22 push: true23 tags: ghcr.io/${{ github.repository }}:latest24 - name: Deploy on VPS25 uses: appleboy/ssh-action@v126 with:27 host: ${{ secrets.SSH_HOST }}28 username: ${{ secrets.SSH_USER }}29 key: ${{ secrets.SSH_PRIVATE_KEY }}30 script: |31 cd /opt/site32 docker compose pull33 docker compose up -d
4. Why this beats plain rsync
- Rollback is one command: retag or
docker compose upan 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.iowith a token that hasread: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.