BuildWithMatija
  1. Home
  2. Blog
  3. Docker
  4. GitHub Actions SSH Deploy to VPS: Run Dev & Prod Safely

GitHub Actions SSH Deploy to VPS: Run Dev & Prod Safely

Step-by-step guide using GitHub Actions, appleboy/ssh-action and Docker Compose port offsets to run dev and prod on…

25th May 2026·Updated on:3rd June 2026··
Docker
GitHub Actions SSH Deploy to VPS: Run Dev & Prod Safely

🐳 Docker & DevOps Implementation Guides

Complete Docker guides with optimization techniques, deployment strategies, and automation prompts to streamline your containerization workflow.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

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.

Contents

  • What You're Building
  • The Full Deploy Flow
  • Step 1: Generate the SSH Deploy Key
  • Step 2: One-Time Server Setup
  • Step 3: The GitHub Actions Workflow
  • Step 4: The Deploy Script
  • Step 5: The Docker Compose Files
  • Step 6: The pnpm Script Chain
  • Triggering and Verifying a Deploy
  • Updating mediamtx.yml in Production
  • Rollback
  • Repo Access for Private Repositories
  • Common Issues
  • FAQ
  • Conclusion
On this page:
  • What You're Building
  • The Full Deploy Flow
  • Step 1: Generate the SSH Deploy Key
  • Step 2: One-Time Server Setup
  • Step 3: The GitHub Actions Workflow
Build with Matija Logo

Build with Matija

Moderne Websites, Content-Systeme und KI-Workflows für langfristiges Wachstum.

Leistungen

  • Headless-CMS-Websites
  • Next.js- & Headless-CMS-Beratung
  • KI-Systeme & Automatisierung
  • Website- & Content-Audit

Ressourcen

  • Case Studies
  • Wie ich arbeite
  • Blog
  • Themen
  • CMS-Hub
  • E-Commerce-Hub
  • B2B-Website-Strategie
  • Dashboard

Headless CMS

  • Payload CMS Entwickler
  • CMS-Migration
  • Multi-Tenant CMS
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Kontakt

Bereit, deinen Stack zu modernisieren? Lass uns über dein Vorhaben sprechen.

Discovery Call buchenKontakt aufnehmen →
© 2026Build with Matija•Alle Rechte vorbehalten•Datenschutzerklärung•Nutzungsbedingungen
BuildWithMatija
Get In Touch

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

The folder layout on the server looks like this:

text
/home/matija/
├── sports-stream/           ← DEV clone (you manage this manually)
│   └── apps/server/
│       └── docker-compose.dev.yml  (ports 9554, 9890, …)
│
└── sports-stream-prod/      ← PROD clone (GitHub Actions manages this)
    └── apps/server/
        └── docker-compose.yml      (ports 8554, 8890, …)

The two compose files run the same container image on different host ports. That's the entire isolation mechanism.


The Full Deploy Flow

Before touching any config, it helps to see the whole pipeline in one shot:

text
Push to master (touching apps/server/**)
        │
        ▼
GitHub Actions triggers workflow
        │
        ▼
appleboy/ssh-action SSHes into VPS as $SERVER_USER
        │
        ├─ If /home/matija/sports-stream-prod doesn't exist:
        │    git clone https://github.com/… /home/matija/sports-stream-prod
        │
        ├─ git pull origin master
        ├─ pnpm install
        └─ pnpm --filter @whcp/server run deploy
                │
                ▼
         scripts/deploy.sh
                │
                ├─ Guard: exits if mediamtx.yml is missing
                ├─ docker compose pull
                └─ docker compose up -d

The deploy script never calls docker compose down before up -d. Docker Compose handles in-place recreation automatically when it detects a new image.


Step 1: Generate the SSH Deploy Key

Create a dedicated key pair for GitHub Actions. Keep this separate from your personal SSH keys.

bash
ssh-keygen -t ed25519 -f ~/.ssh/github-actions-deploy -C "github-actions-deploy"

Install the public key on your VPS:

bash
ssh matija@YOUR_SERVER_IP
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "PASTE_PUBLIC_KEY_HERE" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Then go to your GitHub repo → Settings → Secrets and variables → Actions and create three secrets:

SecretValue
SSH_PRIVATE_KEYFull contents of ~/.ssh/github-actions-deploy
SERVER_HOSTYour VPS IP or hostname
SERVER_USERYour SSH login user

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 packages
sudo 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

Verify everything is installed:

bash
docker --version
docker compose version
git --version
node --version
pnpm --version

The newgrp docker command applies the group change immediately. If that causes issues, log out and back in instead.

Next, create the production clone and its config file:

bash
git clone https://github.com/matija2209/sports-stream.git /home/matija/sports-stream-prod
cd /home/matija/sports-stream-prod
cp apps/server/mediamtx.yml.example apps/server/mediamtx.yml
nano apps/server/mediamtx.yml
mkdir -p apps/server/recordings

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.

Finally, configure the firewall:

bash
sudo ufw allow OpenSSH
sudo ufw allow 8554/tcp   # RTSP
sudo ufw allow 8890/udp   # SRT
sudo ufw allow 8888/tcp   # HLS
sudo ufw allow 8889/tcp   # WebRTC HTTP signaling
sudo ufw allow 8189/udp   # WebRTC UDP
sudo ufw enable

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
# 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

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.


Step 4: The Deploy Script

bash
# File: apps/server/scripts/deploy.sh
#!/usr/bin/env bash
set -euo pipefail

cd /home/matija/sports-stream-prod/apps/server

if [ ! -f mediamtx.yml ]; then
  echo "ERROR: mediamtx.yml not found. Run:"
  echo "  cp apps/server/mediamtx.yml.example apps/server/mediamtx.yml"
  echo "  and fill in real passwords before deploying."
  exit 1
fi

echo "=== Pulling latest MediaMTX image ==="
docker compose pull

echo ""
echo "=== Starting containers ==="
docker compose up -d

echo ""
echo "=== Container status ==="
docker compose ps

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
# File: apps/server/docker-compose.yml
services:
  mediamtx:
    image: bluenviron/mediamtx:1.18.2
    container_name: whcp-mediamtx
    restart: unless-stopped

    ports:
      - "8554:8554/tcp"    # RTSP
      - "8890:8890/udp"    # SRT
      - "8888:8888/tcp"    # HLS
      - "8889:8889/tcp"    # WebRTC HTTP
      - "8189:8189/udp"    # WebRTC UDP
      - "127.0.0.1:9997:9997/tcp"   # API (localhost only)
      - "127.0.0.1:9998:9998/tcp"   # Metrics (localhost only)

    environment:
      - MTX_RTSPTRANSPORTS=tcp

    volumes:
      - ./mediamtx.yml:/mediamtx.yml:ro
      - ./recordings:/recordings

Development — apps/server/docker-compose.dev.yml:

yaml
# File: apps/server/docker-compose.dev.yml
services:
  mediamtx:
    image: bluenviron/mediamtx:1.18.2
    container_name: whcp-mediamtx-dev
    restart: unless-stopped

    ports:
      - "9554:8554/tcp"    # RTSP (dev)
      - "9890:8890/udp"    # SRT (dev)
      - "9888:8888/tcp"    # HLS (dev)
      - "9889:8889/tcp"    # WebRTC HTTP (dev)
      - "9189:8189/udp"    # WebRTC UDP (dev)
      - "127.0.0.1:19997:9997/tcp"  # API (dev, localhost only)
      - "127.0.0.1:19998:9998/tcp"  # Metrics (dev, localhost only)

    environment:
      - MTX_RTSPTRANSPORTS=tcp

    volumes:
      - ./mediamtx.yml:/mediamtx.yml:ro
      - ./recordings:/recordings

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:

bash
pnpm --filter @whcp/server run deploy
  → apps/server/package.json: "deploy": "bash scripts/deploy.sh"
  → scripts/deploy.sh

The apps/server/package.json also exposes scripts for local dev management:

json
// File: apps/server/package.json
{
  "scripts": {
    "up":      "docker compose -p whcp-dev -f docker-compose.dev.yml up -d",
    "down":    "docker compose -p whcp-dev -f docker-compose.dev.yml down",
    "restart": "docker compose -p whcp-dev -f docker-compose.dev.yml restart",
    "logs":    "docker compose -p whcp-dev -f docker-compose.dev.yml logs -f mediamtx",
    "health":  "MTX_API_PORT=19997 bash scripts/healthcheck.sh",
    "deploy":  "bash scripts/deploy.sh"
  }
}

And the root package.json exposes workspace-level aliases:

json
// File: package.json
{
  "scripts": {
    "server:up":      "pnpm --filter @whcp/server up",
    "server:down":    "pnpm --filter @whcp/server down",
    "server:restart": "pnpm --filter @whcp/server restart",
    "server:logs":    "pnpm --filter @whcp/server logs",
    "server:health":  "pnpm --filter @whcp/server health",
    "deploy:server":  "pnpm --filter @whcp/server deploy"
  }
}

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.

Test the pipeline end to end:

bash
echo "# test" >> apps/server/README.md
git add apps/server/README.md
git commit -m "chore: trigger deploy test"
git push origin master

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.


Repo Access for Private Repositories

The current workflow clones with HTTPS:

bash
git clone https://github.com/matija2209/sports-stream.git /home/matija/sports-stream-prod

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.

OptionWhen to useNotes
Public repo + HTTPS cloneSimplestWorks out of the box
Private repo + SSH clone + deploy keyRecommendedSeparate server Git access from GitHub Actions SSH
Private repo + GitHub token over HTTPSAcceptableRequires careful secret handling on the VPS

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

SymptomLikely causeFix
Permission denied (publickey) in GitHub ActionsWrong key, wrong user, or missing authorized_keys entryReinstall the deploy public key and re-save SSH_PRIVATE_KEY
Permission denied (publickey) during git clone on VPSRepo is private, VPS has no Git credentialsAdd deploy key or token for repo access
docker: permission deniedServer user not in docker groupsudo usermod -aG docker <user> and re-login
pnpm: command not foundpnpm not installed on VPSnpm install -g pnpm
mediamtx.yml not foundConfig file was never created on productionCopy mediamtx.yml.example and fill real values
Port already in useDev/prod collision or another process holding the portInspect listeners and adjust host port mappings
Workflow did not triggerCommit didn't touch apps/server/** or branch was not masterPush a server change to master or use workflow_dispatch

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