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
- 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'
-
Store the private key and connection info in GitHub Secrets (never in the repo):
DEPLOY_KEY,SERVER_HOST,SERVER_USER,SERVER_PORT. -
Consider restricting the deploy user to the target directories — e.g., via
sudo -uor 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_keysentries 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