Deploying Docker Compose to a VPS with GitHub Actions
- Author
- Vishal Maurya
- Published on
- Reading time
- 4 min read
Overview
You have a Docker Compose app running on a VPS and you are tired of SSHing in, running git pull and rebuilding by hand. The goal is a push to main that builds an image, publishes it, and updates the server. Building on the server wastes its CPU and memory, so the cleaner pattern is: build in CI, push to a registry, and have the server only pull and restart.
1. Reference an Image, Not a Build Context, in Production
On the server, the compose file should point to a registry image whose tag you control.
# docker-compose.yml on the VPS
services:
api:
image: ghcr.io/your-user/your-app:${IMAGE_TAG:-latest}
env_file: .env
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000"
Binding to 127.0.0.1 keeps the port private so a reverse proxy on the host is the only public entry. Using an IMAGE_TAG variable lets each deploy choose an exact version rather than relying on latest.
2. Build and Push from GitHub Actions
GitHub Container Registry works with the built-in GITHUB_TOKEN, so no extra registry secret is needed.
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
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 }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
Check the current major versions of each action in its repository, since they change over time. Image names must be lowercase; if your username has capitals, lowercase it in a prior step.
3. Create a Deploy-Only SSH Key
Do not reuse your personal key. Generate a dedicated key pair, add the public half to ~/.ssh/authorized_keys on the VPS for a non-root deploy user, and store the private half as a GitHub secret.
ssh-keygen -t ed25519 -f deploy_key -C "github-actions-deploy" -N ""
Add these repository secrets: VPS_HOST, VPS_USER, VPS_SSH_KEY, and VPS_KNOWN_HOSTS. Get the last value with ssh-keyscan your-host from a machine you trust, so the workflow verifies the server identity instead of blindly accepting it.
4. Deploy Over SSH
Add a second job that runs after the build.
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Configure SSH
run: |
install -m 700 -d ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.VPS_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Pull and restart
run: |
ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"cd /opt/app && IMAGE_TAG=${{ github.sha }} docker compose pull api && IMAGE_TAG=${{ github.sha }} docker compose up -d --remove-orphans && docker image prune -f"
The server must be logged in to the registry once (docker login ghcr.io with a token that has read:packages), or the repository can use a public image. docker compose up -d recreates only containers whose image or config changed.
5. Keep Runtime Secrets on the Server
Application secrets such as database passwords belong in a .env file on the VPS with restrictive permissions (chmod 600), not baked into the image and not echoed in workflow logs. GitHub masks registered secrets in logs, but only if they are exact matches, so avoid printing derived values. Keep the SSH key secret scoped to the repository or to a protected environment with required reviewers if you want a manual approval gate.
6. Add Basic Safety
- Use a
concurrencygroup so two pushes do not deploy at the same time. - Make the compose file part of the repo and copy it to the server in the deploy step if it changes, for example with
scp. - Add a healthcheck and verify it after deploy (see the related post on automatic rollback).
- Restrict the deploy user: it needs Docker access, which is effectively root-equivalent, so protect that key accordingly.
concurrency:
group: deploy-production
cancel-in-progress: false
Conclusion
Build once in CI, publish an immutable tag, and let the server do nothing but pull and restart. Dedicated SSH keys, verified host keys and server-side runtime secrets keep the pipeline simple and reasonably safe.
If you want this pipeline set up or hardened for your own VPS, I can help. Contact me.