AI Automation

Deploy CI/CD to a VPS with GitHub Actions and SSH

The first time you push a fix and realize you still have to SSH into the VPS, git pull, rebuild, restart, the excitement fades fast. Doing that three times a day for a production app is not sustainable. A CI/CD pipeline from GitHub Actions straight to your VPS over SSH turns every push into a deploy without you touching the terminal.

This guide targets a Linux VPS running Ubuntu 24.04 LTS with Docker Compose v2. You will wire a GitHub Actions workflow that connects via SSH key, pulls the latest image, and restarts the service stack, with rollback safety built in.

通过 SSH 和 GitHub Actions 部署 CI/CD 流水线,每次推送代码自动更新 VPS 上的应用。

CI/CD deployments over SSH with GitHub Actions update the application on your VPS automatically on every push.

Prerequisites

  • A Linux VPS running Ubuntu 24.04 LTS or Debian 12 (a Linux VPS with full root access and a dedicated IPv4).
  • Docker and Docker Compose v2 installed on the VPS (docker compose with a space, not the v1 hyphen form).
  • A GitHub repository for your application code.
  • SSH access to the VPS using an ED25519 key pair (better than RSA for modern deployments).
  • Basic familiarity with YAML syntax and GitHub repository settings.

Why automate SSH-based deployment instead of using a Docker Registry

A registry-based flow (push image to Docker Hub, pull on the VPS) is clean, but it adds latency and a dependency on the registry provider. When your VPS and code live close to each other and your app is a small single-binary or a few Docker services, pulling over SSH from a Git clone or a local build is faster, simpler, and works behind restrictive firewalls. You also keep full control, no registry tokens, no rate limits, no image leaks.

The trade-off is that the VPS must have enough disk and RAM to build or pull the image locally. For a typical 2-4 GB RAM VPS running a Node.js or Python backend, that is not a problem. If your build is heavy (large Rust or Go artifact, lots of npm layers), a dedicated GitLab VPS or a self-hosted runner may suit better.

Step 1, Prepare the SSH key on the VPS

The workflow needs an SSH key that can log into the VPS without a password. Generate a dedicated deploy key, never reuse your personal key for automation.

On your local machine (or directly on the VPS):

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""

Copy the public key to the VPS. The cleanest way is ssh-copy-id:

ssh-copy-id -i ~/.ssh/deploy_key.pub root@your-vps-ip

If ssh-copy-id is not available, append the key manually:

cat ~/.ssh/deploy_key.pub | ssh root@your-vps-ip "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"

Verify the key works:

ssh -i ~/.ssh/deploy_key root@your-vps-ip "hostname"

You should see the VPS hostname printed, with no password prompt.

Step 2, Store the SSH key as a GitHub secret

Never embed the private key in your repository. Add it as a repository secret in GitHub.

On your local machine, copy the private key content:

cat ~/.ssh/deploy_key

Go to your GitHub repository → SettingsSecrets and variablesActionsNew repository secret. Add:

  • Name: SSH_PRIVATE_KEY
  • Value: paste the entire private key (including the -----BEGIN OPENSSH PRIVATE KEY----- header and footer).

Add a second secret for the VPS host/IP:

  • Name: SSH_HOST
  • Value: your VPS IP address (e.g. 103.xxx.xxx.xxx) or domain if DNS is set up.

Add a third secret if you want to store the username:

  • Name: SSH_USER
  • Value: root (or your non-root sudo user name).

Treat SSH keys as the most sensitive secret in your pipeline. Per GitHub's 2026 security roadmap, pin the appleboy/ssh-action version to its SHA commit hash for deterministic dependency locking.

Step 3, Write the Docker Compose file

On your VPS, create the application directory and a compose.yaml file. The v2 Docker Compose format prefers compose.yaml over docker-compose.yml.

Example for a simple web service with Nginx + a Node.js backend:

mkdir -p /opt/myapp
nano /opt/myapp/compose.yaml
services:
  app:
    image: myapp:latest
    build: .
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
  web:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app

The workflow will git pull the latest code, rebuild (or pull) the Docker image, and docker compose up -d to restart only what changed. Docker Compose v2's up -d handles zero-downtime by default when services use rolling updates.

Step 4, Create the GitHub Actions workflow

Inside your repository, create the workflow file:

mkdir -p .github/workflows
nano .github/workflows/deploy.yml

Write the workflow:

name: Deploy to VPS

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/myapp
            git pull origin main
            docker compose pull
            docker compose up -d --build
            docker image prune -f

Explanation of steps:

  • git pull origin main, pulls the latest source to the VPS. The repository must be cloned once manually: git clone [email protected]:youruser/yourrepo.git /opt/myapp.
  • docker compose pull, pulls updated images from the registry if you are using pre-built images.
  • docker compose up -d --build, rebuilds the image if a Dockerfile is present (the build: . directive) and restarts services that changed. Using --build forces a rebuild for services with build stanza.
  • docker image prune -f, removes unused (dangling) images to keep disk from filling up. This matters on a small VPS with limited storage.

Verify the workflow: push a change to main, go to the Actions tab, watch the run. If it fails, the error message tells you exactly where, missing SSH key, wrong path, or a Docker build error.

Step 5, Secure the pipeline with nftables and fail2ban

Your VPS now accepts SSH connections from GitHub's runner IPs on every deploy. That is normal, but you should still lock down SSH access aggressively, especially if the VPS has a public IP. Use nftables (the default firewall on Ubuntu 24.04) to restrict SSH to trusted IP ranges if possible, or at least rate-limit it.

Create an nftables rule set (/etc/nftables.conf) that only allows SSH from your own office IP and GitHub's meta IP ranges:

table inet filter {
  chain input {
    type filter hook input priority 0;
    policy drop;
    ct state invalid drop
    ct state {established, related} accept
    iif lo accept
    tcp dport 22 ip saddr { 192.168.0.0/16, 140.82.112.0/20, 185.199.108.0/22 } accept
    tcp dport 80 accept
    tcp dport 443 accept
    icmp type echo-request limit rate 10/second accept
  }
}

Replace the GitHub CIDR ranges with the current published list from api.github.com/meta (they change periodically). Load the rules:

nft -f /etc/nftables.conf
systemctl enable nftables
systemctl start nftables

Install fail2ban as a second layer:

apt install fail2ban -y
cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
systemctl enable fail2ban --now

This guards against brute-force attempts even if the pipeline's SSH key is somehow exposed.

Troubleshooting

The workflow runs but the app does not update

Most likely the SSH user does not have permission to write to the deploy directory. Ensure the user owns /opt/myapp and can run docker commands. If you use a non-root user, add them to the docker group: usermod -aG docker youruser. Log out and back in for the group change to take effect.

SSH connection refused or host key verification fails

Add the host key to the SSH known hosts file. In the appleboy/ssh-action, you can pass host_key (the public key fingerprint). The easier approach: make the first manual SSH connection from the runner environment itself (run a temporary test workflow) and inspect the error. For CI, set STRICT_HOST_KEY_CHECKING: no in the action input as a temporary measure, then pin the host key once it works.

Docker compose up returns "no space left on device"

Unused images pile up. Add more aggressive pruning: docker system prune -a -f runs after every deploy. Or use a daily cron job on the VPS to clean up. If the VPS has a small disk (20 GB NVMe), consider moving Docker data to a larger attached volume.

Git pull fails because of local changes

If the VPS has uncommitted changes (e.g., a modified config file during debugging), git pull errors out. The script can force a reset: replace git pull origin main with git fetch --all && git reset --hard origin/main. Be careful, this discards any local changes on the VPS.

FAQ

Is this approach suitable for zero-downtime deployments?

Yes, if your Docker Compose file uses rolling updates (default for replicated services) and your application handles graceful shutdown signals. The docker compose up -d command restarts only the services whose configuration or image changed, leaving running containers untouched. For a single-instance production stack on a small VPS, you may still see a sub-second gap. Adding a health check to the Compose file helps Docker manage the switchover cleanly.

Can I use this with a non-root user on the VPS?

Yes, and it is recommended. Create a user such as deploy, add it to the docker group, and use its SSH key for the GitHub action. This limits the blast radius if the key is compromised, the deploy user cannot modify system files or install packages.

How do I handle environment variables for the application?

Store sensitive variables (database passwords, API keys) as GitHub secrets and pass them to the SSH action's script, or write them to a .env file on the VPS. The simplest approach: keep a .env file in /opt/myapp/.env that the Compose file reads via the env_file directive. The workflow does not touch it, it lives only on the VPS.

What about self-hosted GitHub runners?

A self-hosted runner on the VPS avoids SSH entirely, the workflow runs locally. This removes network latency and firewall issues, but GitHub announced a $0.002/minute charge for self-hosted runners starting in March 2026 (currently postponed for re-evaluation). For a single VPS, the SSH approach remains cheaper and simpler.

Should I use docker-compose.yml or compose.yaml?

Docker Compose v2 prefers compose.yaml. If you use the old docker-compose.yml file, rename it. The v2 tooling reads either, but compose.yaml is the standard in 2026.

How do I revert a bad deploy?

Keep the previous Docker image tag. Modify the workflow to accept a git revert on the VPS: cd /opt/myapp && git revert HEAD --no-edit && docker compose up -d --build. Or use the docker compose up -d --no-build with an older image tag. Better: implement a canary check in the workflow that runs a smoke test against the new container before stopping the old one.

Related articles

GitHub Actions 与 VPS 的 CI/CD 自动部署指南

本文详细介绍了如何通过 GitHub Actions 的 SSH action 在越南 VPS 上搭建 CI/CD 流水线。核心做法包括:生成专用的 ED25519 SSH 密钥并存入 GitHub Secrets、在 VPS 上使用 Docker Compose v2 的 compose.yaml 管理服务、配合 nftables 防火墙限制 SSH 来源、利用 fail2ban 进行二次防护。每次推送 main 分支后,工作流会自动拉取代码、重新构建镜像并重启服务。对于越南用户来说,选择一台拥有本地 IPv4 和低延迟国内带宽的 Linux VPS,能大幅提升部署的稳定性和响应速度。

Note: This guide is for general reference. Every system and infrastructure has its own specifics, so test each step in a safe environment and consult a qualified engineer before applying it in production.