I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.
GitHub Actions SSH Deploy: Running Dev and Production on the Same VPS
If you have a single VPS and want GitHub Actions to automatically deploy your Docker service on every push — while keeping a dev environment running on the same machine — this guide walks through the exact pattern I use for a real production setup.
The approach is straightforward: two git clones of the same repo, side by side on the server, with Docker Compose port offsets to prevent collisions. GitHub Actions manages the prod clone over SSH. You manage the dev clone manually. No staging server required, no infrastructure complexity beyond a single machine.
I built this for a MediaMTX streaming service running in a pnpm monorepo, but the pattern applies to any Docker-based service. Everything shown here is live in production.
What You're Building
By the end of this guide you'll have:
A GitHub Actions workflow that triggers on changes to apps/server/** and SSHes into your VPS
A bash deploy script with a config file guard, docker compose pull, and docker compose up -d
Two Docker Compose files with non-overlapping ports so dev and prod can run simultaneously
A clean separation between what GitHub Actions owns and what you manage manually
Then go to your GitHub repo → Settings → Secrets and variables → Actions and create three secrets:
Secret
Value
All three must exist before the workflow runs. A missing secret causes an immediate auth failure with no useful error message, so set them all before pushing anything.
Step 2: One-Time Server Setup
SSH into the server and install the required toolchain:
bash
# Base packagessudo apt update
sudo apt install -y ca-certificates curl git
# Docker Engine and Compose plugin
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
# Node.js (the workflow runs pnpm on the VPS)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# pnpm
npm install -g pnpm
The mediamtx.yml file is gitignored because it contains passwords. It must exist before the first deploy — the deploy script checks for it and exits hard if it's missing.
Leave ports 9997 and 9998 closed. Those are the MediaMTX API and metrics endpoints, bound to 127.0.0.1 in the compose file and only accessible over an SSH tunnel.
Step 3: The GitHub Actions Workflow
Create .github/workflows/deploy-server.yml:
yaml
A few things worth calling out:
The paths filter is critical. It means pushes that only touch apps/api, apps/web, or the Android app never trigger this workflow. Without the filter, every commit would kick off a deploy regardless of what changed.
set -euo pipefail at the top of the SSH script means any failing command aborts the entire block immediately. If pnpm install fails, the deploy script never runs. This is the behavior you want — a partial deploy is worse than no deploy.
workflow_dispatch lets you trigger the workflow manually from the GitHub UI. Useful for re-running a deploy without making a dummy commit.
The mediamtx.yml guard is the first thing the script checks. If the config file was never created on the server, the deploy aborts with a readable error message rather than starting a container with no runtime config.
After the guard, docker compose pull downloads the pinned image, and docker compose up -d creates or recreates the container. The final docker compose ps writes container status directly into the GitHub Actions log, so you can confirm the container is running without SSHing into the server.
Step 5: The Docker Compose Files
The key to running dev and prod on the same machine is host port offsets. Production uses 8xxx, dev uses 9xxx.
Production — apps/server/docker-compose.yml:
yaml
Development — apps/server/docker-compose.dev.yml:
yaml
The container image is the same in both files. The container names differ (whcp-mediamtx vs whcp-mediamtx-dev), and all host ports are shifted by 1000. Both containers can run at the same time with no conflicts.
The API and metrics ports (9997, 9998 in prod, 19997, 19998 in dev) are bound to 127.0.0.1, so they never appear on the public network even if the firewall rule were accidentally added.
Step 6: The pnpm Script Chain
The workflow calls pnpm --filter @whcp/server run deploy. Here is how that resolves through the workspace:
The apps/server/package.json also exposes scripts for local dev management:
json
And the root package.json exposes workspace-level aliases:
json
Notice that pnpm server:health uses port 19997, the dev API port. To health-check production, you SSH into the server and run the healthcheck directly from the prod folder with MTX_API_PORT=9997.
Triggering and Verifying a Deploy
Automatic: Push any change under apps/server/** to master.
Manual: GitHub → Actions → "Deploy server" → "Run workflow". Select master as the branch.
Then watch GitHub Actions → "Deploy server" → most recent run.
Verify on the server:
bash
ssh whcp-dev
cd /home/matija/sports-stream-prod/apps/server
# Container state
docker compose ps
# Full healthcheck (prod)
MTX_API_PORT=9997 bash scripts/healthcheck.sh
Updating mediamtx.yml in Production
The runtime config is not managed by GitHub Actions. To update it:
bash
ssh whcp-dev
nano /home/matija/sports-stream-prod/apps/server/mediamtx.yml
cd /home/matija/sports-stream-prod/apps/server
docker compose restart mediamtx
A git pull alone does not pick up mediamtx.yml changes because the file is gitignored. The restart is what applies the new config.
Rollback
There is no automated rollback in the workflow. The manual path:
bash
ssh whcp-dev
cd /home/matija/sports-stream-prod
git log --oneline -5
git checkout <previous-good-commit>
pnpm install
pnpm --filter @whcp/server run deploy
If the issue is only in mediamtx.yml:
bash
cd /home/matija/sports-stream-prod/apps/server
cp mediamtx.yml.bak mediamtx.yml
docker compose restart mediamtx
For more reliable rollback in future iterations, pinning explicit image tags in the compose files (rather than floating references) is the natural next step.
That works as written only when the repo is public. For private repos, you need to configure Git credentials on the VPS before the workflow can clone or pull.
If you replicate this for a private repo, change the clone and pull commands to use an SSH remote and install a deploy key on the VPS for repo access.
Common Issues
FAQ
Do I need a staging server for this pattern to be safe?
No. The mediamtx.yml guard in the deploy script prevents the container from starting without a valid config. set -euo pipefail prevents a partial deploy from proceeding silently. Those two mechanisms cover the most common failure modes without requiring a staging environment.
What happens if the container is already running when the deploy script runs?
docker compose up -d handles it. If a new image is available after docker compose pull, Compose recreates the container automatically. If the image hasn't changed, the running container is left untouched.
Can I run the deploy script locally to test it?
Yes. SSH into the server and run it directly:
bash
ssh whcp-dev
cd /home/matija/sports-stream-prod
pnpm install
pnpm --filter @whcp/server run deploy
Or invoke the script directly:
bash
cd /home/matija/sports-stream-prod/apps/server
bash scripts/deploy.sh
What if I have multiple services, not just apps/server?
Add a separate workflow file per service with its own paths filter. Each workflow can SSH into the same server and call a different deploy script. The two-folder pattern scales by adding a new <project>-prod clone per service.
Why does pnpm install run at the repo root in the workflow?
The workflow runs from the root of the monorepo, which means pnpm install processes the full workspace. A scoped install (pnpm --filter @whcp/server install) would be faster, but the root install is simpler and correct. It's worth scoping if the workspace grows significantly.
Conclusion
The two-folder dev/prod pattern on a single VPS is a practical setup for small to medium projects where full staging infrastructure isn't worth the overhead. GitHub Actions handles the production clone, you handle the dev clone, and Docker Compose port offsets keep both running without conflicts.
The three pieces that make this reliable are the mediamtx.yml guard in the deploy script, set -euo pipefail in both the workflow script and the deploy script, and the paths filter in the workflow so unrelated changes don't trigger unnecessary deploys.
Let me know in the comments if you have questions about adapting this to a different service or a private repo setup, and subscribe for more practical development guides.
Thanks,
Matija
SSH_PRIVATE_KEY
Full contents of ~/.ssh/github-actions-deploy
SERVER_HOST
Your VPS IP or hostname
SERVER_USER
Your SSH login user
# File: .github/workflows/deploy-server.yml
name:
Deploy
server
on:
push:
branches:
-
master
paths:
-
"apps/server/**"
workflow_dispatch:
jobs:
deploy:
name:
Deploy
MediaMTX
to
whcp-dev
runs-on:
ubuntu-latest
steps:
-
name:
Deploy
via
SSH
uses:
appleboy/ssh-action@v1.0.3
with:
host:
${{
secrets.SERVER_HOST
}}
username:
${{
secrets.SERVER_USER
}}
key:
${{
secrets.SSH_PRIVATE_KEY
}}
script:
|
set -euo pipefail
if [ ! -d /home/matija/sports-stream-prod ]; then
git clone https://github.com/matija2209/sports-stream.git /home/matija/sports-stream-prod
fi
cd /home/matija/sports-stream-prod
git pull origin master
pnpm install
pnpm --filter @whcp/server run deploy