A deployment script is a shell file — usually deploy.sh — that turns ship this version to production
into a single, repeatable command. Instead of clicking through dashboards or stitching together ad-hoc SSH sessions, you run one script that knows how to pull the right code, install dependencies, run database migrations, restart services, and verify the result. Done well, it removes the human-error surface that causes most outages.
This guide covers what belongs in a deployment script, a working deploy.sh example you can adapt, the patterns that separate hobby scripts from production-grade ones, and where deployment scripts fit alongside build scripts and build pipelines in a modern release workflow.
What is a Deployment Script?
A deployment script is a set of automated instructions that handle the process of deploying applications or services to one or more environments. Where a build script prepares the artifact (compiles, bundles, packages), a deployment script takes that artifact and puts it on the target servers — safely, predictably, and without manual intervention.
In production-grade workflows, deployment scripts typically:
- Pull the right release artifact (a tagged Docker image, a built directory, a versioned tarball)
- Copy or sync files to one or more servers
- Install runtime dependencies pinned to the release
- Run database migrations in the correct order
- Restart application services with zero-downtime semantics where possible
- Perform health checks before traffic is switched
- Roll back automatically when any of the above fails
- Handle environment-specific configuration (staging vs. production)
- Manage Docker containers and registry authentication
The script is the single source of truth for how a deploy happens.
Two people running it should get the exact same result — that is the whole point.
Why You Need One (Even on Small Projects)
Manual deploys are the most expensive form of technical debt: they look free until the 2 AM incident when the person who knows the steps is asleep. A deployment script forces you to write down every step explicitly, which surfaces three things teams routinely get wrong:
- Hidden state — environment variables,
chmod +x, a particular Nginx reload sequence — that nobody documented - Ordering bugs — running migrations after restarting the app instead of before, restarting workers before the queue is drained
- Missing rollback paths — every team has one, but only the script forces you to write the rollback alongside the deploy
Once that knowledge lives in version control, anyone can ship, anyone can review, and the script becomes the contract between the developer and the production environment. This is the foundation for automatic deployments from Git — you can't automate what you can't script.
Key Components of a Deployment Script
Environment Variables
Environment variables let one script handle multiple environments without branching code. They typically include:
DB_HOST=production-db.example.com
API_KEY=your-secret-key
ENVIRONMENT=production
DOCKER_REGISTRY=registry.example.com
RELEASE_TAG=v2.14.3
Load them from .env files, your CI secrets store, or an encrypted vault — never hard-code them. DeployHQ stores per-environment config so the same script runs unchanged across staging and production.
Commands and Operations
The core of every deployment script is a short, well-ordered set of commands:
# Install dependencies (pinned to the lockfile)
npm ci --omit=dev
# Build the application
npm run build
# Restart services
pm2 reload app --update-env
# Docker operations
docker build -t myapp:$RELEASE_TAG .
docker compose up -d --remove-orphans
A few notes on production hygiene worth calling out:
- Use
npm ci(notnpm install) — it respectspackage-lock.jsonand refuses to install drifted versions - Use
pm2 reloadoverpm2 restartfor zero-downtime worker swaps - Use
docker compose(the v2 plugin), not the deprecateddocker-composebinary
Error Handling
Half-finished deploys are worse than failed ones. Every production deploy script should fail loudly and immediately:
#!/bin/bash
set -euo pipefail
# -e: exit on any error
# -u: exit on undefined variables
# -o pipefail: catch errors in piped commands
if ! npm ci --omit=dev; then
echo "Failed to install dependencies"
exit 1
fi
set -euo pipefail is the single most important line in any production shell script. Without it, a failed curl or undefined variable will keep executing until the script appears to succeed — and you only find out when traffic stops.
Security Considerations
- Use encrypted secrets (Vault, AWS Secrets Manager, DeployHQ's encrypted config variables)
- Implement least-privilege access — the deploy user should only own what it deploys
- Avoid hardcoding sensitive information; even environment variables shouldn't be
echod during deploy - Use SSH key authentication for file transfers, not passwords
- Authenticate to your Docker registry with short-lived tokens, not personal credentials
Creating Your First Deployment Script
Here's a production-ready deploy.sh for a Node.js application that you can copy into any repo and adapt. It handles the common failure modes (missing env vars, failed migration, unhealthy service) and prints clear progress markers.
#!/bin/bash
# deploy.sh — production deploy script for a Node.js app
set -euo pipefail
# --- Load environment ---
if [ ! -f .env ]; then
echo "FATAL: .env file missing"
exit 1
fi
source .env
: "${RELEASE_TAG:?RELEASE_TAG must be set}"
: "${DB_HOST:?DB_HOST must be set}"
echo "→ Starting deployment of $RELEASE_TAG"
# --- Install ---
echo "→ Installing production dependencies..."
npm ci --omit=dev
# --- Build ---
echo "→ Building application..."
npm run build
# --- Database migrations ---
echo "→ Running database migrations..."
npm run migrate
# --- Restart with zero downtime ---
echo "→ Reloading application..."
pm2 reload app --update-env
# --- Health check ---
echo "→ Verifying health..."
for i in {1..30}; do
if curl -sf http://localhost:3000/health > /dev/null; then
echo "✓ Deployment of $RELEASE_TAG complete"
exit 0
fi
sleep 2
done
echo "✗ Health check failed after 60s — rolling back"
pm2 reload app --update-env --revert
exit 1
Two things to notice that turn this from a tutorial example into something you'd actually run in production:
: "${VAR:?msg}"— the parameter-expansion idiom that exits immediately if a required variable is missing, before any side effects- The health-check loop with a rollback path — most deployment scripts skip this, which is exactly how broken releases reach users
Best Practices
- Always include proper error handling (
set -euo pipefail) - Add meaningful logging with timestamps for post-mortems
- Break complex tasks into shell functions so each step is independently testable
- Include cleanup procedures (temp files, old artifacts) — disks fill up surprisingly fast
- Version your scripts in the same repo as the code they deploy
- Test in a staging environment that mirrors production before any production change
Ready to skip the boilerplate? DeployHQ runs your deployment scripts on every commit with automatic deployments from Git, with secrets, rollbacks, and SSH keys handled for you. Start a free trial — no credit card required.
Advanced Deployment Script Features
Conditional Execution
Same script, different environments:
if [ "$ENVIRONMENT" = "production" ]; then
echo "→ Running production-specific tasks..."
npm run build:production
else
echo "→ Running development build..."
npm run build:dev
fi
Rolling Back Deployments
A deploy script without a rollback path is half a deploy script. The pattern below snapshots the current release before deploying and restores it on any failure:
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/var/backups/app"
DEPLOY_DIR="/var/www/app"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
create_backup() {
echo "→ Creating backup..."
mkdir -p "$BACKUP_DIR"
cp -r "$DEPLOY_DIR" "$BACKUP_DIR/backup-$TIMESTAMP"
}
rollback() {
echo "→ Rolling back to previous version..."
LATEST_BACKUP=$(ls -t "$BACKUP_DIR" | head -n1)
rm -rf "$DEPLOY_DIR"/*
cp -r "$BACKUP_DIR/$LATEST_BACKUP"/* "$DEPLOY_DIR"/
}
create_backup
if ! ./deploy.sh; then
echo "✗ Deployment failed, rolling back..."
rollback
exit 1
fi
This is a working but minimal rollback. Production-grade rollbacks need atomic symlink swaps so traffic flips instantly rather than during a file copy — DeployHQ does this natively with one-click rollback (covered below).
Docker Container Deployment
For containerised applications, the deployment script's job changes: it builds and pushes an image, then tells the runtime to switch to the new tag. The same set -euo pipefail and health-check patterns still apply.
#!/bin/bash
set -euo pipefail
IMAGE_NAME="myapp"
CONTAINER_NAME="myapp-container"
DOCKER_REGISTRY="registry.example.com"
TAG=$(git rev-parse --short HEAD)
echo "→ Building Docker image $IMAGE_NAME:$TAG"
docker build -t "$IMAGE_NAME:$TAG" \
--build-arg NODE_ENV=production \
.
docker tag "$IMAGE_NAME:$TAG" "$DOCKER_REGISTRY/$IMAGE_NAME:$TAG"
docker tag "$IMAGE_NAME:$TAG" "$DOCKER_REGISTRY/$IMAGE_NAME:latest"
echo "→ Pushing to registry"
docker push "$DOCKER_REGISTRY/$IMAGE_NAME:$TAG"
docker push "$DOCKER_REGISTRY/$IMAGE_NAME:latest"
echo "→ Replacing container"
docker stop "$CONTAINER_NAME" 2>/dev/null || true
docker rm "$CONTAINER_NAME" 2>/dev/null || true
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p 3000:3000 \
-e NODE_ENV=production \
-e DB_HOST="$DB_HOST" \
-v /var/log/myapp:/app/logs \
"$DOCKER_REGISTRY/$IMAGE_NAME:$TAG"
echo "→ Health check"
for i in {1..30}; do
if curl -sf http://localhost:3000/health > /dev/null; then
echo "✓ Container is healthy"
exit 0
fi
sleep 2
done
echo "✗ Health check failed"
exit 1
Docker Compose Configuration
For multi-service stacks, Compose handles the orchestration and your script just calls it:
# docker-compose.yml
services:
app:
build:
context: .
args:
- NODE_ENV=production
image: ${DOCKER_REGISTRY}/myapp:${TAG}
container_name: myapp-container
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DB_HOST=${DB_HOST}
volumes:
- /var/log/myapp:/app/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
Multi-Service Deployments
When you've outgrown a single container, the script grows to push and update multiple services in dependency order:
#!/bin/bash
set -euo pipefail
SERVICES=("frontend" "backend" "cache" "worker")
REGISTRY="registry.example.com"
TAG=$(git rev-parse --short HEAD)
docker login "$REGISTRY" -u "$DOCKER_USER" --password-stdin <<< "$DOCKER_PASSWORD"
for service in "${SERVICES[@]}"; do
echo "→ Processing $service"
docker build -t "$REGISTRY/$service:$TAG" -f "./$service/Dockerfile" "./$service"
docker push "$REGISTRY/$service:$TAG"
done
if [ "$ENVIRONMENT" = "production" ]; then
for service in "${SERVICES[@]}"; do
kubectl set image "deployment/$service" "$service=$REGISTRY/$service:$TAG"
done
for service in "${SERVICES[@]}"; do
kubectl rollout status "deployment/$service" --timeout=5m
done
fi
Note the explicit --timeout on kubectl rollout status — without it, a stuck rollout will block your pipeline forever instead of failing cleanly.
Deployment Scripts in CI/CD
A deployment script doesn't live in isolation. It's the last step of a build pipeline that compiles, tests, and packages your code. Before the script runs, your pipeline should have already executed deployment testing — unit, integration, and smoke tests against the built artifact. The deploy script's only job is the safe transfer to production, often coordinated by a native build pipeline runner that handles parallel test execution and artifact caching.
This separation matters: if your deploy script
also runs tests, you've conflated two failure modes. Test failures should never reach the production environment. Keep the deployment script narrow and let the pipeline do the rest.
Using Deployment Scripts with DeployHQ
DeployHQ runs your deployment scripts on every commit and handles the parts that go wrong when you build this yourself — SSH keys, secrets, parallel server fan-out, atomic releases, rollback history.
Basic Configuration
# Settings configured in DeployHQ for a Node.js project
scripts:
pre_deployment:
- npm ci --omit=dev
- npm run build
deployment:
- ./deploy.sh
post_deployment:
- ./health-check.sh
Docker-Specific Configuration
# Settings configured in DeployHQ for a Docker project
scripts:
pre_deployment:
- docker login $DOCKER_REGISTRY -u $DOCKER_USER -p $DOCKER_PASSWORD
deployment:
- docker compose build
- docker compose push
- docker compose down
- docker compose up -d
post_deployment:
- ./verify-deployment.sh
What DeployHQ Adds
- Per-environment scripts and config variables
- Full execution logs for every deploy
- Zero-downtime deployments via atomic symlink swaps
- One-click rollback to any previous release
- Direct integration with GitHub, GitLab, and Bitbucket
- Optional deploys from your terminal with DeployHQ Agents
Best Practices and Tips
Version Control
Deployment scripts belong in the same repo as the code they deploy. They're as load-bearing as the application code itself.
# .gitignore
.env
node_modules/
dist/
# Don't ignore deployment scripts
!scripts/deploy.sh
!docker-compose.yml
Docker-Specific Best Practices
- Image Tagging Strategy
Use the git commit SHA — never latest for production. latest makes rollbacks ambiguous and breaks reproducibility.
TAG="${GIT_COMMIT:-$(git rev-parse --short HEAD)}"
ENV_TAG="$ENVIRONMENT-$TAG"
docker tag $IMAGE_NAME:$TAG $IMAGE_NAME:$ENV_TAG
- Clean Up Old Images
Container hosts run out of disk faster than you expect. Keep a sliding window:
cleanup_old_images() {
docker images "$IMAGE_NAME" --format "{{.ID}}" | \
sort -r | awk 'NR>5' | \
xargs -r docker rmi
}
- Resource Limits
Always cap memory and CPU. An unbounded container can take out the whole host:
docker run \
--memory="512m" \
--memory-swap="1g" \
--cpus="0.5" \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
$IMAGE_NAME:$TAG
Troubleshooting and Debugging
Common Issues and Solutions
- Permission Errors
chmod +x deploy.sh
chown -R deploy:deploy /var/www/app
- Docker Issues
# Logs and state
docker logs $CONTAINER_NAME --tail 100
docker ps -a
docker inspect $CONTAINER_NAME
# When you've made a mess
docker system prune -f
- Network Issues
# Network state
docker network ls
docker network inspect bridge
# Cross-container connectivity
docker exec $CONTAINER_NAME ping database
Health Checks
A standalone health-check script you can call from post_deployment or run on a cron to detect drift between deploys:
#!/bin/bash
# health-check.sh
set -euo pipefail
check_service() {
local service_url=$1
local max_retries=30
local wait_time=2
echo "→ Checking $service_url"
for ((i=1; i<=max_retries; i++)); do
if curl -sf "$service_url/health" > /dev/null; then
echo "✓ Service healthy"
return 0
fi
echo " Attempt $i/$max_retries — waiting ${wait_time}s..."
sleep $wait_time
done
echo "✗ Service health check failed after $((max_retries * wait_time))s"
return 1
}
check_service "http://localhost:3000" # app
check_service "http://localhost:3001" # api
check_service "http://localhost:3002" # worker
Conclusion
Deployment scripts are the contract between your code and your production environment. A good one is short, explicit, fails loudly, and rolls back automatically. A bad one is the reason your team dreads Friday deploys.
The patterns above — set -euo pipefail, required-variable checks, health-check loops, automatic rollback — are what separate a script you write once and forget from one you trust at 3 AM during an incident. Combine them with atomic, zero-downtime releases and the entire failure surface shrinks dramatically.
Key Takeaways
- Treat deployment scripts as production code: version-controlled, reviewed, tested
- Always use
set -euo pipefailand validate required environment variables - Build the rollback path before you build the deploy path
- Use git SHAs as image tags — never
latestfor production - Cap container resources to protect the host
- Add a health-check loop with a clear failure exit
Ready to stop maintaining bespoke deploy scripts on every project? Try DeployHQ's pricing plans — every tier includes unlimited deployments, atomic releases, and rollbacks.
Questions about deploying with DeployHQ? Email support@deployhq.com or reach us on X (@deployhq).
This post is part of our What Is
series, helping developers understand key concepts and methodologies in modern software development.