← All guides
Deploy a static site to your VPS with Docker and GitHub Actions
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 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.