Automated CI/CD Pipelines: Deploying to Servers with GitHub Actions

A real story: a four-person team maintained a community site with 20,000 daily users, and deploys always meant "someone manually SSHes in, runs git pull, and restarts." One Monday morning, code that had never run the test suite got merged into main, went live at ten, and started failing in production. By the time anyone noticed, it was after lunch — an entire morning of conversions was lost. The post-mortem was not about the code; it was about the process: releases depended on a person, there was no automation, and no rollback.

The end goal of CI/CD is "auto-publish to production after a merge." For most small and mid-sized projects running on a VPS or cloud server, the most common shape is: GitHub Actions builds in the cloud, pushes artifacts to the server over SSH, then runs the deployment commands on the server. This is the automated upgrade of the Git auto-deploy workflow.

1. Overall Architecture

push main
  → GitHub Actions (build, test)
    → scp upload build artifacts to server
      → SSH runs the deploy script (rsync / pull image / restart container)
        → health check

In this chain, CI is responsible for "reproducibly building an artifact," and the server just "swaps the artifact and restarts." Responsibilities are cleanly separated, so when something breaks it is easy to locate.

2. Preparation: SSH Keys and Secrets

  1. Create a dedicated deploy user on the server and generate a key pair:
adduser deployer
mkdir -p /var/www/app && chown -R deployer:deployer /var/www/app
ssh-keygen -t ed25519 -a 200 -f ~/.ssh/deploy_key
cat ~/.ssh/deploy_key.pub | ssh deployer@server 'cat >> ~/.ssh/authorized_keys'
  1. Store the private key and connection info in GitHub Secrets (never in the repo): DEPLOY_KEY, SERVER_HOST, SERVER_USER, SERVER_PORT.

  2. Consider restricting the deploy user to the target directories — e.g., via sudo -u or a whitelist of allowed commands — so a leaked key cannot turn into full lateral access to the server. For host preparation, see the server initialization script.

3. Workflow: SCP Upload + SSH Execution

The example below uses the community-standard appleboy/scp-action and appleboy/ssh-action:

name: Deploy to Server

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Upload via SCP
        uses: appleboy/scp-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          source: "dist/*"
          target: /var/www/app/dist

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            cd /var/www/app
            systemctl reload nginx
            curl -fsS http://127.0.0.1/healthz || exit 1

Key points:

  • Use an SSH key rather than a password; the private key lives only in GitHub Secrets, so it never appears in logs;
  • Upload only the build artifacts (dist/*) so other files on the server are never overwritten;
  • Run a health check after deployment so failures turn the pipeline red instead of silently shipping a bad version.

To make "turning red" recoverable, keep a rollback-friendly layout on the server. For a static site, keep the last two builds and switch the live version with a symlink:

# server-side deploy.sh (invoked by the SSH step)
VERSION=$1
ln -sfn /var/www/releases/$VERSION /var/www/current
systemctl reload nginx
curl -fsS http://127.0.0.1/healthz || {
  ln -sfn /var/www/releases/$((VERSION-1)) /var/www/current
  systemctl reload nginx
  exit 1
}

This way "a failed new version auto-falls back to the previous one" instead of overwriting in place. Symlink swapping is idempotent no matter how many times the pipeline reruns, so the server never lands in a half-deployed state; and splitting "deploy" from "verify" into separate steps means a failed verification only reruns the check, not the upload.

4. Containerized Deployment

If the target server runs Docker, turn the SSH script into building/pulling images and rolling out containers:

script: |
  cd /opt/app
  docker compose pull web
  docker compose up --no-deps -d web
  sleep 5
  curl -fsS http://127.0.0.1:8080/healthz || exit 1

Combined with the health checks and restart policies in Docker Compose production deployment, this yields a basically automated rolling release. For more complete blue/green and canary strategies see zero-downtime deployment strategies.

5. Deployment Approaches Compared

Approach Automation Rollback speed Fits
Manual SSH + git pull Low Slow Getting started
Actions + SCP/rsync static artifacts High Fast (symlink swap) Static sites, SPAs
Actions + image registry + container restart High Medium (image = version) Node/Python services, multi-instance
Full K8s / ArgoCD Very high Very fast (auto-rollback) Large microservices

For most small and mid-sized projects, rows two and three are enough — do not adopt Kubernetes just to "have CD."

6. Key Security

  • Secrets always live in GitHub Secrets, referenced in workflows via ${{ secrets.XXX }};
  • Do not grant the deploy user root privileges; restrict write access on target directories;
  • Rotate deployment keys periodically (e.g., every 90 days) and remove old authorized_keys entries on the server;
  • A more advanced option is replacing long-lived keys with short-lived OIDC tokens; see Docker OIDC support for GitHub Actions.

7. Leveling Up: Building Images and Pushing to a Registry

At slightly larger scale, a more robust approach is: Actions builds a Docker image, pushes it to a registry (GHCR/Docker Hub), and the server only pulls and restarts. This fully separates build and runtime environments and keeps artifacts traceable:

- name: Build and push image
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: ghcr.io/yourorg/app:${{ github.sha }}

The image SHA is the version number. Rolling back is just pulling the previous tag, and debugging pinpoints the exact commit.

8. Common Questions

Q: Actions reports "Permission denied (publickey)"? Usually a key setup problem: make sure the private key is pasted intact into DEPLOY_KEY, authorized_keys on the server is chmod 600, and the deploy user's home directory is not writable by others.

Q: Every deploy is slow? Check whether dependencies are reinstalled from scratch each time. Use actions/cache for node_modules or the pnpm store to cut most of the install time, and add concurrency so only one deploy runs per branch.

Q: Nothing changed after the deploy? Verify that the upload target matches the web server's document root, and that Nginx/Apache actually reloaded.

9. Comparison with GitLab

Teams using GitLab can reference GitLab CI/CD best practices; the two platforms share the same idea: build on the CI side, deploy via SSH/containers. For the full pipeline concept see setting up a website CI/CD pipeline, and for GitHub Actions fundamentals see the GitHub Actions CI/CD tutorial.

16IDC Note

"GitHub Actions + SSH" is the most cost-effective automated deployment shape for small and mid-sized projects: no Kubernetes, no self-hosted CI server, and "push to publish" instead of manual SCP uploads and manual restarts. Its real value is not saving a few minutes but making the release process repeatable, traceable and rollback-ready. Starting with SCP uploads and gradually evolving to container images plus rolling releases is a solid growth path.

Reference: https://docs.github.com/en/actions , https://github.com/appleboy/scp-action , https://github.com/appleboy/ssh-action