Automate Deploying Your Node.js App to a VPS with GitHub Actions & Docker Compose
A step-by-step guide to a simple, secure, and reproducible CI/CD pipeline.
TL;DR
- Generate an SSH key pair, store the private key in your GitHub repo’s Secrets.
-
Create a GitHub Actions workflow that, on every push to
main
, SSHes into your VPS and runsdocker-compose pull && docker-compose up -d
. -
Structure your VPS with one project folder per app, each containing its own
docker-compose.yml
.
Why This Matters
Manually deploying via SSH and git pull
on a VPS (DigitalOcean, OVH, Scaleway, etc.) works at first—but as your team and release cadence grow, manual steps lead to missed updates and unexpected downtime. Pairing GitHub Actions with Docker Compose gives you:
- Atomic deployments: Docker images are versioned and immutable.
- Instant rollbacks: Revert to a previous commit in seconds.
- Clear visibility: Build and deploy logs accessible in GitHub’s UI.
1. Prerequisites
- A Linux VPS (Ubuntu 20.04+ recommended) with Docker & Docker Compose installed.
-
A GitHub repo containing:
- Your Node.js source code (
package.json
, etc.). - A
Dockerfile
that builds your app. - A
docker-compose.yml
defining at minimum yourweb
service (and any dependencies).
- Your Node.js source code (
2. Set Up SSH Authentication
- On your local machine, generate a key without passphrase:
ssh-keygen -t rsa -b 4096 -C "deploy@your-domain" -f ~/.ssh/id_rsa_vps
- Copy the public key to your VPS:
ssh-copy-id -i ~/.ssh/id_rsa_vps.pub root@your-vps-ip
-
In GitHub, add a new Secret named
SSH_PRIVATE_KEY
, and paste in the contents of~/.ssh/id_rsa_vps
. -
(Optional) Add a Secret
SSH_KNOWN_HOSTS
containing the output of:
ssh-keyscan -H your-vps-ip
This pins your VPS’s fingerprint.
3. Sample Dockerfile
# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]
4. Sample docker-compose.yml
version: "3.8"
services:
web:
image: your-dockerhub-user/your-app:${GITHUB_SHA::8}
build:
context: .
ports:
- "3000:3000"
restart: always
We tag images with the first 8 characters of the Git commit SHA to trace exactly what’s running.
5. GitHub Actions Workflow
Create .github/workflows/deploy.yml
in your repo:
name: CI/CD to VPS
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up SSH
uses: webfactory/ssh-agent@v0.8.1
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: (Optional) Add known_hosts
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- name: Build Docker image
run: |
docker build \
--tag your-dockerhub-user/your-app:${GITHUB_SHA::8} \
.
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push image
run: |
docker push your-dockerhub-user/your-app:${GITHUB_SHA::8}
- name: Deploy to VPS
run: |
ssh root@your-vps-ip << 'EOF'
cd /srv/your-app
export GITHUB_SHA=${GITHUB_SHA}
docker-compose pull
docker-compose up -d
EOF
Key benefits
- Security: Private key never leaves GitHub Actions.
-
Atomic switch:
docker-compose pull
fetches the specific tagged image, thenup -d
swaps containers instantly. -
Rollback: Redeploy a prior commit by resetting
main
to an older SHA.
6. Best Practices & Next Steps
-
Build cache: Accelerate builds with
actions/cache@v3
+ BuildKit. -
Matrix builds: Test against multiple Node.js versions with a
strategy.matrix
. - Alerts: Add a Slack or Microsoft Teams step to notify on failures.
-
Reusable scripts: Encapsulate deploy logic in
scripts/deploy.sh
for clarity and reuse. -
Zero-downtime: Consider rolling updates with Docker Compose v2’s
deploy
options or switch to Docker Swarm/Kubernetes as you scale.
Conclusion
With about 15 lines of YAML and a pair of SSH keys, you’ll have a robust, transparent CI/CD pipeline for any VPS-hosted project and improve your web app. You’ll gain reliability, speed, and traceability—and deployments will finally be… automatic!
Give it a try: adapt this approach to Python, Go, or PHP stacks, add automated tests or security scans, and share your experiences in the comments.
Top comments (0)