What the Quick‑Setup Looks Like
The most common “quick‑start” that new engineers copy from a blog post is a tiny Bash script that runs every five minutes via cron. The script opens an SSH connection to the target host, runs git pull, and restarts the service. On the surface it seems harmless, but beneath the surface a cascade of subtle failures can jeopardize availability, security, and compliance.
# /etc/cron.d/deploy-sync
*/5 * * * * deployuser /usr/local/bin/deploy_sync.sh
The deploy_sync.sh script typically contains:
#!/usr/bin/env bash
set -euo pipefail
# SSH key is stored in the home directory of deployuser
SSH_KEY="/home/deployuser/.ssh/id_rsa"
TARGET="[email protected]"
REPO_DIR="/var/www/app"
ssh -i "$SSH_KEY" "$TARGET" <<'EOF'
cd "$REPO_DIR"
git pull origin main
systemctl restart app.service
EOF
This pattern is attractive because it requires no CI/CD platform, no extra credentials, and only a single line in cron. However, the simplicity is deceptive.
Why This Approach Is Risky
Below are the most common failure modes that are rarely discussed in “how‑to” guides.
1. Stale State and Race Conditions
If the cron job fires while a previous deployment is still running, the second invocation can corrupt the working directory. Git does not protect against concurrent git pull calls; you may end up with a partially merged state, broken dependencies, or a half‑started service.
# Naïve lock file – easily bypassed
LOCKFILE="/tmp/deploy.lock"
if [ -e "$LOCKFILE" ]; then
echo "Deploy already running"
exit 0
fi
touch "$LOCKFILE"
# ... deployment steps ...
rm -f "$LOCKFILE"
Even with a lock file, a crash or power loss can leave the lock file orphaned, causing future runs to abort silently. A robust solution must use an atomic lock (e.g., flock) and include timeout handling.
2. Credential Leakage
Storing a private SSH key on the deployment host makes it a single point of failure. If the host is compromised, an attacker gains unfettered read/write access to the repository and any other servers that trust the same key. Moreover, the key is often added to authorized_keys without command restrictions, allowing arbitrary SSH sessions.
# In authorized_keys – bad practice
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... deployuser@ci
A safer pattern restricts the key to a single command and disables port forwarding:
# In ~/.ssh/authorized_keys on the target
command="/usr/local/bin/receive_deploy.sh",no-port-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3... deployuser@ci
3. Lack of Audit Trail
Cron does not provide a built‑in audit log of which commit was deployed, who triggered it, or whether the deployment succeeded. This makes post‑mortem investigations painful and can violate compliance requirements such as SOC 2 or ISO 27001.
# Append a simple audit entry – still insufficient
echo "$(date) – $(git rev-parse HEAD) – $(whoami)" >> /var/log/deploy_audit.log
A proper audit trail should be immutable, include the full commit metadata, and be stored in a centralized logging system.
4. No Rollback Mechanism
If a bad commit lands, the script simply rolls forward. There is no automated way to revert to the previous known‑good state, forcing operators to perform manual git reset --hard steps that are error‑prone.
# Manual rollback – risky
git reset --hard HEAD~1
systemctl restart app.service
A robust pipeline should capture the previous commit hash before the update and provide a one‑click rollback command.
Hidden Internals of a Safer Alternative
Rather than a bare cron job, consider a minimal CI/CD runner that executes the same steps but adds atomicity, credential isolation, and observability. Below is a lightweight approach using GitHub Actions and a self‑hosted runner on the target machine. The runner authenticates via a short‑lived token, eliminating long‑lived SSH keys.
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install dependencies
run: |
npm ci # or pip install -r requirements.txt
- name: Run migrations
run: |
./manage.py migrate
- name: Restart service
run: |
sudo systemctl restart app.service
The self‑hosted runner runs as a dedicated system user with sudo rights limited to the service restart command via /etc/sudoers.d/app:
# /etc/sudoers.d/app
deployrunner ALL=(root) NOPASSWD: /bin/systemctl restart app.service
Benefits of this model:
- Each run receives a fresh, time‑limited GitHub token – no persistent keys.
- GitHub records the exact commit SHA, author, and job outcome in the UI and via API.
- Jobs are queued; concurrency is handled by the runner, eliminating race conditions.
- Rollback can be scripted as a separate workflow that checks out the previous tag and redeploys.
Implementing a Manual Rollback Workflow
The following workflow demonstrates how to revert to the previous tag. It can be triggered manually from the GitHub UI.
# .github/workflows/rollback.yml
name: Rollback Production
on:
workflow_dispatch:
inputs:
tag:
description: 'Git tag to roll back to'
required: true
jobs:
rollback:
runs-on: self-hosted
steps:
- name: Checkout specific tag
uses: actions/checkout@v3
with:
ref: ${{ github.event.inputs.tag }}
- name: Restart service with rolled‑back code
run: |
sudo systemctl restart app.service
By keeping a lightweight CI/CD system, you retain the “single‑script” mental model while gaining auditability, concurrency control, and secure credential handling.
Security and Best Practices
Never store long‑lived SSH keys on production hosts. Use short‑lived tokens, GitHub‑issued runners, or cloud‑native secret managers.
Enforce immutable logging. Ship logs to a centralized system (e.g., Elastic, Loki) with write‑once storage.
Apply process isolation. Run the deployment runner inside a minimal container or a systemd sandbox to limit the blast radius of a compromise.
<