What Is a Deployment Script? deploy.sh Examples and Best Practices

Devops & Infrastructure, Tips & Tricks, Tutorials, and What Is

What Is a Deployment Script? deploy.sh Examples and Best Practices

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:

  1. Hidden state — environment variables, chmod +x, a particular Nginx reload sequence — that nobody documented
  2. Ordering bugs — running migrations after restarting the app instead of before, restarting workers before the queue is drained
  3. 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 (not npm install) — it respects package-lock.json and refuses to install drifted versions
  • Use pm2 reload over pm2 restart for zero-downtime worker swaps
  • Use docker compose (the v2 plugin), not the deprecated docker-compose binary

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

  1. Always include proper error handling (set -euo pipefail)
  2. Add meaningful logging with timestamps for post-mortems
  3. Break complex tasks into shell functions so each step is independently testable
  4. Include cleanup procedures (temp files, old artifacts) — disks fill up surprisingly fast
  5. Version your scripts in the same repo as the code they deploy
  6. 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

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

  1. 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
  1. 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
   }
  1. 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

  1. Permission Errors
   chmod +x deploy.sh
   chown -R deploy:deploy /var/www/app
  1. 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
  1. 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 pipefail and validate required environment variables
  • Build the rollback path before you build the deploy path
  • Use git SHAs as image tags — never latest for 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.