Introduction: The All‑Too‑Common “Git Pull + Rsync” Script
Many small teams start their continuous delivery journey with a one‑liner
shell script that pulls the latest commit from a GitHub repository over SSH
and mirrors the files to a remote host using rsync. The
approach feels lightweight, requires no extra services, and can be
scheduled with cron. On the surface it appears sufficient for
a handful of static assets.
The article does not aim to provide a step‑by‑step “how‑to” for this pattern. Instead, it uncovers the hidden internals that make the method brittle when applied to production workloads, especially those that involve multiple regions, large binary artifacts, or strict compliance requirements.
What the Script Looks Like – A Minimal Example
Below is a trimmed version of the script that many tutorials showcase. It runs on a bastion host, authenticates with a password‑less SSH key, pulls the repository, and pushes the working tree to the target server.
#!/usr/bin/env bash
# deploy.sh – simplistic Git‑SSH‑Rsync pipeline
# Configuration
REPO="[email protected]:example/app.git"
BRANCH="main"
TARGET_USER="deployer"
TARGET_HOST="10.0.2.15"
TARGET_DIR="/var/www/app"
# Ensure we have a clean worktree
if [[ -d repo ]]; then
rm -rf repo
fi
git clone --depth 1 --branch "$BRANCH" "$REPO" repo
# Rsync to remote host
rsync -avz --delete repo/ "$TARGET_USER@$TARGET_HOST:$TARGET_DIR"
The script is short, easy to understand, and can be wrapped in a cron
entry to run every five minutes. However, each line hides a cascade of
assumptions that do not hold in real‑world production environments.
Hidden Assumption #1 – Atomicity Is Guaranteed
rsync transfers files incrementally. If a deployment is
interrupted—by a network glitch, a reboot, or a throttled SSH session—the
target directory can end up in a partially updated state. Subsequent
requests may encounter mismatched binaries or missing configuration files,
leading to runtime errors that are hard to reproduce.
To see the problem, insert a deliberate pause in the middle of a transfer:
# Insert artificial delay after 50% of files
rsync -avz --partial --progress repo/ "$TARGET_USER@$TARGET_HOST:$TARGET_DIR" \
--exclude='*.log' \
--rsync-path="sleep 5 && rsync"
During the pause the remote service may start serving a mixture of old and new files. A production system that expects all files to be consistent cannot tolerate this window.
Hidden Assumption #2 – File Permissions Remain Correct
When rsync copies files it preserves the source permissions
unless overridden. If the local repository contains developer‑specific
ownership (e.g., uid 1000), those IDs are reproduced on the
remote host. On a hardened server where the application must run under a
dedicated user, this can cause permission denials or, worse, expose
privileged files to the wrong account.
A safer copy would reset ownership explicitly:
rsync -avz --chown=appuser:appgroup \
--chmod=Du+rwx,Dgo+rx,Fu+rw,Fgo+r \
repo/ "$TARGET_USER@$TARGET_HOST:$TARGET_DIR"
Even with --chown, the remote side must have the target user
and group defined, otherwise the command fails silently in some SSH
configurations.
Hidden Assumption #3 – Network Latency Is Negligible
A single SSH connection must traverse the entire path from the CI host to the target server. In multi‑region deployments this latency can exceed a second per round‑trip, inflating the total transfer time dramatically. Moreover, the SSH session is vulnerable to idle‑timeouts imposed by firewalls or load balancers, which abort the transfer mid‑stream.
The following snippet demonstrates a keep‑alive configuration that many developers forget to set:
# ~/.ssh/config
Host *
ServerAliveInterval 30
ServerAliveCountMax 5
While this mitigates some timeout issues, it does not solve the underlying problem that a single monolithic transfer is fragile. Segmenting the deployment into smaller, verifiable chunks is a more resilient pattern.
Hidden Assumption #4 – No Secret Leakage Occurs
The script checks out the repository directly on the CI host. If the repository contains environment‑specific secrets (API keys, database passwords) in plain text, they are exposed on the bastion machine and potentially written to the shell’s history. Even when the repository is private, the SSH key used for cloning grants read access to all branches, including feature branches that may contain experimental credentials.
A more secure pattern is to keep secrets out of the repository entirely
and inject them at deployment time via a vault or secret manager. For
illustration, here is a short example using aws ssm get-parameter:
# Fetch secret from AWS SSM Parameter Store
DB_PASSWORD=$(aws ssm get-parameter \
--name "/prod/app/db_password" \
--with-decryption \
--query Parameter.Value --output text)
export DB_PASSWORD
rsync -avz repo/ "$TARGET_USER@$TARGET_HOST:$TARGET_DIR"
The secret never lands in the repository and is only available to the deployment process for the brief moment it is needed.
Hidden Assumption #5 – Auditing and Rollback Are Trivial
With a simple pull‑and‑push script there is no built‑in record of which commit was deployed, who triggered it, or whether the deployment succeeded. If a bug is discovered, rolling back requires manually checking out the previous tag and re‑executing the same script, a process prone to human error.
Adding explicit logging and tagging can improve visibility:
# Record deployment metadata
DEPLOY_ID=$(uuidgen)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
COMMIT_SHA=$(git -C repo rev-parse HEAD)
cat > deploy.log <<EOF
DEPLOY_ID=$DEPLOY_ID
TIMESTAMP=$TIMESTAMP
COMMIT=$COMMIT_SHA
TARGET=$TARGET_HOST
STATUS=STARTED
EOF
# After rsync succeeds
ssh "$TARGET_USER@$TARGET_HOST" "touch $TARGET_DIR/.deployed_$DEPLOY_ID"
echo "STATUS=SUCCESS" >> deploy.log
While this adds some traceability, it still lacks the atomicity and consistency guarantees of a dedicated deployment service.
Security and Best Practices
If you must continue using a lightweight script for low‑risk workloads, follow these hardening steps:
- Use a dedicated, short‑lived SSH certificate instead of a static key.
- Restrict the remote user’s
authorized_keysto a forced command that only allows the requiredrsyncinvocation. - Enable
rsync’s--checksummode for integrity verification when network reliability is questionable. - Wrap the entire operation in a transaction‑style lock file on the remote host to prevent concurrent deployments.
- Never store secrets in the repository; pull them from a vault at runtime.
“A deployment pipeline is not a script; it is a contract between code and infrastructure. When the contract is implicit, failure is inevitable.”
Conclusion
Related Insights
Continue exploring Cloud & DevOps: