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:
- Runs when code is pushed to the
mainbranch - Connects to a VPS using SSH
- Goes to the project directory on the server
- Pulls the latest code from GitHub
- Rebuilds and restarts the Docker container
- Waits for the container to become healthy
- Fails the deployment if the container does not become healthy
- 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:
- You push commit A
- Deployment starts
- Before it finishes, you push commit B
- 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_actionsfor the private key~/.ssh/x-project_github_actions.pubfor 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:
- Builds the Docker image again if needed
- Starts the containers
- 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:
startinghealthyunhealthymissing
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_HOSTSSH_USERSSH_PRIVATE_KEYSSH_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
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.