Skip to main content

CI/CD Automation with Rsync

Rsync is a lightweight, efficient deployment tool for CI/CD pipelines. Instead of building Docker images or using complex orchestration, you can deploy applications by rsyncing build artifacts directly to your servers.

Why Rsync for CI/CD?

ApproachComplexitySpeedBest For
RsyncLowFast (only changed files)VPS/dedicated, traditional hosting
DockerMedium-HighModerateContainer-based infrastructure
KubernetesHighVariableLarge-scale microservices
FTP/SFTPLowSlow (full upload)Legacy hosting only

Rsync is ideal when:

  • You're deploying to VPS or dedicated servers
  • You want fast, incremental deployments
  • You don't need container orchestration
  • You want simple, auditable deployment scripts

SSH Key Setup for CI/CD

Every CI/CD system needs SSH access to your server. Create a dedicated deployment key:

# Generate deployment key (no passphrase for automation)
ssh-keygen -t ed25519 -C "ci-deploy-key" -f deploy_key -N ""

# Copy public key to server
ssh-copy-id -i deploy_key.pub deployuser@production-server

Store the Private Key as a CI Secret

  • GitHub Actions: Settings → Secrets → SSH_PRIVATE_KEY
  • GitLab CI: Settings → CI/CD → Variables → SSH_PRIVATE_KEY
  • Jenkins: Credentials → SSH Username with private key

GitHub Actions

Basic Deployment

.github/workflows/deploy.yml
name: Deploy to Production

on:
push:
branches: [main]

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

- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts

- name: Deploy via rsync
run: |
rsync -avz --delete \
--exclude='.git/' \
--exclude='.github/' \
--exclude='node_modules/' \
--exclude='.env' \
--exclude='storage/logs/' \
-e "ssh -i ~/.ssh/deploy_key" \
./ ${{ secrets.DEPLOY_USER }}@${{ secrets.SERVER_HOST }}:/var/www/html/

With Build Step

.github/workflows/build-and-deploy.yml
name: Build and Deploy

on:
push:
branches: [main]

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

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts

- name: Deploy build artifacts
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key" \
./dist/ ${{ secrets.DEPLOY_USER }}@${{ secrets.SERVER_HOST }}:/var/www/html/

GitLab CI/CD

.gitlab-ci.yml
stages:
- build
- deploy

build:
stage: build
image: node:20
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/

deploy:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache rsync openssh-client
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
- chmod 600 ~/.ssh/deploy_key
- ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts
script:
- rsync -avz --delete
-e "ssh -i ~/.ssh/deploy_key"
./dist/
$DEPLOY_USER@$SERVER_HOST:/var/www/html/
only:
- main
environment:
name: production
url: https://example.com

Zero-Downtime Deployment

Deploy to a new directory, then swap the symlink — no interruption for visitors:

#!/bin/bash
# zero-downtime-deploy.sh
set -euo pipefail

SERVER="deployuser@production"
DEPLOY_DIR="/var/www/releases/$(date +%F_%H%M)"
CURRENT_LINK="/var/www/html"

# Step 1: Create new release directory
ssh "$SERVER" "mkdir -p $DEPLOY_DIR"

# Step 2: Rsync to new directory
rsync -avz --delete \
--exclude='.git/' \
--exclude='node_modules/' \
--exclude='.env' \
./ "$SERVER:$DEPLOY_DIR/"

# Step 3: Copy environment file from current release
ssh "$SERVER" "cp $CURRENT_LINK/.env $DEPLOY_DIR/.env 2>/dev/null || true"

# Step 4: Swap symlink (atomic operation)
ssh "$SERVER" "ln -sfn $DEPLOY_DIR $CURRENT_LINK"

# Step 5: Cleanup old releases (keep last 5)
ssh "$SERVER" "cd /var/www/releases && ls -t | tail -n +6 | xargs -r rm -rf"

echo " Deployed: $DEPLOY_DIR"

Exclude File for Deployments

.rsync-exclude
# Version control
.git/
.gitignore
.github/

# Dependencies (install on server instead)
node_modules/
vendor/

# Environment and secrets
.env
.env.*
*.pem
*.key

# Development files
tests/
*.test.js
*.spec.js
*.md
LICENSE

# Build tools
webpack.config.js
vite.config.js
tsconfig.json
.eslintrc*
.prettierrc*

# OS files
.DS_Store
Thumbs.db

Use in rsync:

rsync -avz --delete --exclude-from='.rsync-exclude' \
./ user@server:/var/www/html/

Post-Deploy Tasks

After rsync completes, run setup commands on the server:

# After deployment, run setup commands via SSH
ssh deployuser@production << 'EOF'
cd /var/www/html

# Install dependencies
composer install --no-dev --optimize-autoloader 2>/dev/null || true
npm ci --production 2>/dev/null || true

# Clear caches
php artisan cache:clear 2>/dev/null || true
php artisan config:cache 2>/dev/null || true

# Fix permissions
sudo chown -R www-data:www-data /var/www/html

# Reload web server
sudo systemctl reload nginx

echo " Post-deploy tasks complete"
EOF

Common Pitfalls

PitfallConsequencePrevention
Deploying .env to serverOverwrites production credentialsAlways exclude .env
Deploying node_modules/Massive, slow transferExclude and npm ci on server
No --delete in deploymentOld files accumulateUse --delete for clean deployments
Plaintext SSH key in CI configKey exposed in logsStore as encrypted secret
No build step before deploySource code deployed, not built assetsBuild first, deploy dist/
No rollback planBroken deploy with no way backUse symlink swap or keep releases
Deploying without dry-run firstWrong files synced to productionRun --dry-run in CI before real deploy

Quick Reference

# Basic CI deployment
rsync -avz --delete --exclude-from='.rsync-exclude' \
-e "ssh -i deploy_key" \
./ user@server:/var/www/html/

# Deploy build artifacts only
rsync -avz --delete \
-e "ssh -i deploy_key" \
./dist/ user@server:/var/www/html/

# Zero-downtime with symlink swap
rsync -avz ./ user@server:/var/www/releases/$(date +%F)/
ssh user@server "ln -sfn /var/www/releases/$(date +%F) /var/www/html"

What's Next