Automated Deployment to a VPS with GitHub Actions, SSH, Git, and Docker Compose

Deploying an application manually can quickly become repetitive.

Every time you push code, you might need to:

  • SSH into your server
  • Pull the latest changes
  • Rebuild your Docker container
  • Restart the application
  • Check if everything is still working

This tutorial shows how to automate that process using GitHub Actions, SSH, and Docker Compose.

By the end, pushing to the main branch will automatically deploy your application to your VPS.


What We Are Building

We will create a GitHub Actions workflow that:

  1. Runs when code is pushed to the main branch
  2. Connects to a VPS using SSH
  3. Goes to the project directory on the server
  4. Pulls the latest code from GitHub
  5. Rebuilds and restarts the Docker container
  6. Waits for the container to become healthy
  7. Fails the deployment if the container does not become healthy
  8. Cleans up unused Docker images

This is a simple but practical production deployment workflow.


Tools Used

Before looking at the workflow, let us understand the tools involved.

GitHub Actions

GitHub Actions is GitHub's automation system.

It lets you run workflows when events happen in your repository, such as:

  • A push to a branch
  • A pull request
  • A manual trigger
  • A scheduled time

In this tutorial, GitHub Actions will run our deployment whenever code is pushed to main.


VPS

A VPS, or Virtual Private Server, is a remote server where your application runs.

Common VPS providers include:

  • DigitalOcean
  • Hetzner
  • Linode
  • Vultr
  • AWS EC2

Your VPS should already have your application repository cloned on it.

Example project path:

/home/developer/myproject

SSH

SSH allows GitHub Actions to connect securely to your VPS.

Instead of logging in manually from your computer, the GitHub Actions runner logs in automatically using an SSH private key.


Docker Compose

Docker Compose is used to define and run your application containers.

A typical app might have:

  • A backend container
  • A database container
  • A Redis container
  • A reverse proxy container

In this workflow, Docker Compose rebuilds and restarts the application after the latest code is pulled.


Example GitHub Actions Workflow

Here is the deployment workflow:

name: Deploy to VPS

on:
  push:
    branches: [main]
  workflow_dispatch:

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

jobs:
  deploy:
    name: SSH deploy
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT || 22 }}
          script_stop: true
          command_timeout: 8m
          script: |
            set -eu

            cd /home/developer/myproject

            echo "==> Pulling latest from origin/main"
            git fetch --prune origin

            git reset --hard origin/main

            git log -1 --pretty=format:'HEAD now at %h %s (%an, %ar)'
            echo

            echo "==> Rebuilding and starting container"
            docker compose up -d --build

            echo "==> Waiting for container to report healthy"
            status="starting"

            for i in $(seq 1 20); do
              status=$(docker inspect --format='{{.State.Health.Status}}' myproject_backend 2>/dev/null || echo "missing")

              if [ "$status" = "healthy" ]; then
                echo "Container is healthy."
                break
              fi

              echo "  attempt $i/20: status=$status; sleeping 3s..."
              sleep 3
            done

            if [ "$status" != "healthy" ]; then
              echo "ERROR: container did not become healthy in 60s. Recent logs:"
              docker compose logs --tail=100 myproject_backend || true
              exit 1
            fi

            echo "==> Pruning dangling images"
            docker image prune -f >/dev/null

            echo "Deploy complete."

Step 1: Create the Workflow File

Inside your project, create this file:

.github/workflows/deploy.yml

Your folder structure should look like this:

your-project/
├── .github/
│   └── workflows/
│       └── deploy.yml
├── docker-compose.yml
├── Dockerfile
└── src/

Paste the workflow into deploy.yml.

Commit and push it to GitHub.


Step 2: Understand the Trigger

This part controls when the deployment runs:

on:
  push:
    branches: [main]
  workflow_dispatch:

It has two triggers.

The first trigger runs when code is pushed to the main branch:

push:
  branches: [main]

The second trigger allows you to run the deployment manually from the GitHub Actions tab:

workflow_dispatch:

This is useful when you want to redeploy without pushing a new commit.


Step 3: Prevent Parallel Deployments

This section is very important:

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

It prevents two production deployments from running at the same time.

Imagine this situation:

  1. You push commit A
  2. Deployment starts
  3. Before it finishes, you push commit B
  4. Another deployment starts immediately

That can cause problems because both deployments may try to update the same files or restart the same containers.

With concurrency, GitHub Actions keeps deployments in order.

The setting:

cancel-in-progress: false

means the current deployment is allowed to finish before the next one starts.

This is safer than cancelling a deployment halfway through.


Step 4: Configure SSH Secrets

The workflow uses GitHub repository secrets:

host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT || 22 }}

You need to add these secrets in GitHub.

Go to:

Repository → Settings → Secrets and variables → Actions → New repository secret

Add the following secrets.

SSH_HOST

This is the IP address or domain of your VPS.

Example:

203.0.113.10

Or:

api.example.com

SSH_USER

This is the Linux user used to connect to the server.

Example:

developer

SSH_PRIVATE_KEY

This is the private SSH key that GitHub Actions will use.

Example format:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

Do not share this key publicly.

SSH_PORT

This is optional.

If your server uses the default SSH port, use:

22

If you changed your SSH port, add the custom port here.


Step 4.1: Generate the SSH Key Pair

Before adding the secret to GitHub, create a dedicated key pair for deployments.

Run these commands on the machine where you are generating the deploy key:

ssh-keygen -t ed25519 \
  -f ~/.ssh/x-project_github_actions \
  -C "github-actions@x-project"

This creates two files:

  • ~/.ssh/x-project_github_actions for the private key
  • ~/.ssh/x-project_github_actions.pub for the public key

Add the public key to the VPS account that will receive the deployment:

cat ~/.ssh/x-project_github_actions.pub >> ~/.ssh/authorized_keys

Then set the recommended permissions:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/x-project_github_actions
chmod 644 ~/.ssh/x-project_github_actions.pub

If you need to copy the private key into the GitHub secret, display it carefully and keep it private:

cat ~/.ssh/x-project_github_actions

Paste that private key into the SSH_PRIVATE_KEY secret in GitHub.


Step 5: Prepare the VPS

Your VPS needs a few things ready before the workflow can work.

Install Git

Check if Git is installed:

git --version

If it is not installed, install it.

On Ubuntu or Debian:

sudo apt update
sudo apt install git -y

Install Docker

Check Docker:

docker --version

Check Docker Compose:

docker compose version

If Docker is missing, install Docker before continuing.


Clone the Repository on the VPS

The workflow expects the project to exist here:

/home/developer/myproject

Clone your repository:

cd /home/developer
git clone git@github.com:your-username/your-repository.git myproject

Then enter the project:

cd /home/developer/myproject

Make sure the server can pull from GitHub:

git fetch origin

Step 6: Pull the Latest Code

This part runs on the VPS:

git fetch --prune origin
git reset --hard origin/main

Let us break it down.

git fetch --prune origin

This downloads the latest Git information from GitHub.

The --prune option removes old remote branch references that no longer exist.

git reset --hard origin/main

This makes the VPS code match the main branch exactly.

This is useful for production servers because the server should not have manual code edits.

Be careful:

git reset --hard origin/main

will remove local changes on the VPS.

That is usually good for deployment, but only if you never edit production code directly.


Step 7: Rebuild and Restart Docker

After pulling the latest code, the workflow runs:

docker compose up -d --build

This does three things:

  1. Builds the Docker image again if needed
  2. Starts the containers
  3. Runs them in the background

The -d option means detached mode.

The --build option tells Docker Compose to rebuild the image before starting the container.

This is important when your code or dependencies have changed.


Step 8: Add a Docker Healthcheck

The workflow checks whether the container becomes healthy:

docker inspect --format='{{.State.Health.Status}}' myproject_backend

This only works if your Docker container has a healthcheck.

Here is an example docker-compose.yml healthcheck:

services:
  myproject:
    container_name: myproject_backend
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

In this example, Docker checks:

http://localhost:3000/health

Your application should return a successful response from that endpoint.

For example, in an Express.js app:

app.get("/health", (req, res) => {
  res.status(200).json({
    status: "ok"
  });
});

Step 9: Wait for the Container to Become Healthy

The workflow waits up to 60 seconds:

for i in $(seq 1 20); do
  status=$(docker inspect --format='{{.State.Health.Status}}' myproject_backend 2>/dev/null || echo "missing")

  if [ "$status" = "healthy" ]; then
    echo "Container is healthy."
    break
  fi

  echo "  attempt $i/20: status=$status; sleeping 3s..."
  sleep 3
done

This loop checks the container health 20 times.

Each check waits 3 seconds.

So the total wait time is:

20 × 3 seconds = 60 seconds

Possible status values include:

  • starting
  • healthy
  • unhealthy
  • missing

If the status becomes healthy, the deployment continues.


Step 10: Fail the Deployment if Healthcheck Fails

If the container does not become healthy, the workflow exits with an error:

if [ "$status" != "healthy" ]; then
  echo "ERROR: container did not become healthy in 60s. Recent logs:"
  docker compose logs --tail=100 myproject || true
  exit 1
fi

This is useful because GitHub Actions will mark the deployment as failed.

It also prints recent logs:

docker compose logs --tail=100 myproject

That helps you debug the problem directly from the GitHub Actions output.


Step 11: Clean Up Old Docker Images

The last step removes dangling Docker images:

docker image prune -f >/dev/null

During repeated builds, Docker can leave unused image layers behind.

Over time, these can take up disk space.

This command removes unused dangling images safely.

Be careful with more aggressive cleanup commands like:

docker system prune -a

That can remove more than expected.


Why set -eu Is Used

At the top of the script, we have:

set -eu

This makes the deployment safer.

The -e option stops the script when a command fails.

The -u option stops the script when an undefined variable is used.

This helps avoid situations where the deployment continues after a serious problem.


Why script_stop: true Is Important

The workflow includes:

script_stop: true

This tells the SSH action to stop when a command fails.

Without this, the script might continue even if something important fails.

For example, if git reset fails, you do not want Docker Compose to restart the old code.


Recommended VPS Permissions

The SSH user should have permission to run Docker commands.

You can add the user to the Docker group:

sudo usermod -aG docker developer

Then log out and log back in.

Test it:

docker ps

If it works without sudo, the user has Docker permission.


Recommended Project Structure

A simple backend project might look like this:

myproject/
├── .github/
│   └── workflows/
│       └── deploy.yml
├── Dockerfile
├── docker-compose.yml
├── package.json
├── src/
│   └── index.js
└── README.md

Your VPS should contain the same project repository.

The workflow does not upload files directly.

Instead, it tells the server to pull the latest code from GitHub.


Common Problems and Fixes

Problem: SSH Connection Fails

Check these values:

  • SSH_HOST
  • SSH_USER
  • SSH_PRIVATE_KEY
  • SSH_PORT

Also make sure your public key is added to the VPS:

~/.ssh/authorized_keys

Problem: Git Pull or Fetch Fails

Make sure the VPS can access the GitHub repository.

Test this on the VPS:

git fetch origin

If the repository is private, the VPS needs its own SSH key with GitHub access.


Problem: Docker Permission Denied

If you see a Docker permission error, add the user to the Docker group:

sudo usermod -aG docker developer

Then reconnect to the server.


Problem: Container Is Missing

The workflow checks this container name:

myproject_backend

Make sure your docker-compose.yml uses the same container name:

container_name: myproject_backend

If the container name is different, update the workflow.


Problem: Healthcheck Never Becomes Healthy

Check that your app has a working health endpoint.

Test it inside the VPS:

curl http://localhost:3000/health

Also inspect the logs:

docker compose logs --tail=100 myproject

Key Considerations

Before using this workflow in production, consider the following points.

1. Do Not Edit Code Directly on the VPS

The command below deletes local changes:

git reset --hard origin/main

Your VPS should be treated as a deployment target, not a place to edit code.

Make changes locally, push to GitHub, and let the workflow deploy them.


2. Use a Dedicated Deploy User

Avoid deploying as root.

Create a dedicated user such as:

developer

Give this user only the permissions it needs.


3. Keep Secrets Out of Git

Do not commit secrets into your repository.

Use environment files or secret managers for values like:

  • Database passwords
  • API keys
  • JWT secrets
  • SMTP credentials

On a VPS, you might store production environment variables in:

.env

Make sure .env is listed in .gitignore.


4. Add a Healthcheck

The healthcheck is what tells the workflow whether the deployment worked.

Without a healthcheck, Docker cannot report whether your app is truly ready.

A running container is not always a working application.


5. Think About Rollbacks

This workflow does not automatically roll back to the previous version.

If deployment fails, the workflow shows logs and exits.

For more advanced production systems, you may want:

  • Image tagging
  • Previous image rollback
  • Blue-green deployment
  • Database migration safety checks

Recommendations

Here are some practical recommendations for improving this deployment setup.

Use Tagged Docker Images Later

For a simple VPS deployment, building directly on the server is fine.

But as your application grows, consider building Docker images in GitHub Actions and pushing them to a registry.

Example registries include:

  • GitHub Container Registry
  • Docker Hub
  • AWS Elastic Container Registry

Then your VPS only pulls and restarts the finished image.


Add Notifications

You may want to send a notification when deployment succeeds or fails.

Common options include:

  • Slack
  • Discord
  • Telegram
  • Email

This is especially useful for production systems.


Back Up Before Risky Changes

Before deploying database-related changes, make sure you have backups.

A deployment script can restart containers, but it cannot protect you from bad migrations or deleted data.


Keep the Workflow Simple

This workflow is good because it is easy to understand.

Avoid making deployment scripts too clever too early.

A simple deployment that you understand is usually better than a complex deployment that is hard to debug.


Improved Version with Environment Variable for Project Path

To make the script easier to reuse, you can define the project path as a variable:

script: |
  set -eu

  APP_DIR="/home/developer/myproject"
  CONTAINER_NAME="myproject_backend"
  SERVICE_NAME="myproject"

  cd "$APP_DIR"

  echo "==> Pulling latest from origin/main"
  git fetch --prune origin
  git reset --hard origin/main

  echo "==> Rebuilding and starting container"
  docker compose up -d --build

  echo "==> Waiting for container to report healthy"
  status="starting"

  for i in $(seq 1 20); do
    status=$(docker inspect --format='{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing")

    if [ "$status" = "healthy" ]; then
      echo "Container is healthy."
      break
    fi

    echo "  attempt $i/20: status=$status; sleeping 3s..."
    sleep 3
  done

  if [ "$status" != "healthy" ]; then
    echo "ERROR: container did not become healthy in 60s. Recent logs:"
    docker compose logs --tail=100 "$SERVICE_NAME" || true
    exit 1
  fi

  docker image prune -f >/dev/null

  echo "Deploy complete."

This makes it easier to change paths, container names, or service names later.


Final Workflow Summary

The deployment flow looks like this:

Push to main
    ↓
GitHub Actions starts
    ↓
Connects to VPS over SSH
    ↓
Goes to project directory
    ↓
Fetches latest code
    ↓
Resets server code to origin/main
    ↓
Rebuilds Docker containers
    ↓
Waits for healthcheck
    ↓
Cleans old Docker images
    ↓
Deployment complete

This gives you a clean and reliable deployment process for a VPS-hosted Docker application.


Conclusion

Automating VPS deployment with GitHub Actions is a great way to reduce manual work and avoid mistakes.

The workflow in this tutorial connects to your VPS over SSH, pulls the latest code, rebuilds the Docker container, checks the container health, and cleans up unused images.

This setup is simple, practical, and beginner-friendly.

It is a strong starting point for deploying small to medium applications on a VPS using Docker Compose.