Bash Deployment Scripts
Write deployment scripts with Bash — CI/CD foundations tutorial, lesson 18.
Focus: write deployment scripts with bash
You've just pushed the winning commit, the pipeline is green, and now the real work begins — getting your application from a build artifact to a live production environment. If you're still copying files to servers or clicking through a web console to deploy, you know the pain: it's slow, error-prone, and impossible to reproduce. This lesson shows you how to write deployment scripts with Bash — the foundational skill that turns manual, fragile deploys into fast, repeatable automation you can run locally or in any CI/CD pipeline.
The problem this lesson solves
Deployment is where good software goes to die unless it's automated. Without a reliable script, every release involves SSH-ing into a server, remembering the right order of commands, and hoping you don't typo a path. The problems are immediate:
- Human error: A missed command or a typo in a filename can take down production.
- Inconsistency: What worked on your laptop might not work on the staging server.
- No rollback path: When something breaks, you're digging through history instead of running one command.
- Audit trail: Nobody knows exactly what was deployed, when, or by whom.
Consider a typical scenario: your team releases every two weeks because the manual deploy takes an afternoon. After reading this lesson, you'll be able to cut that to a single ./deploy.sh invocation — and the same script will run in your GitHub Actions workflow, on a cron job, or from a developer's laptop.
Core concept / mental model
Think of a deployment script as a cookbook for your application's life cycle. Each function is a recipe: build, test, package, deploy, rollback. The script is the chef who follows the recipes in order, checks each step, and reports back with a status.
In technical terms, a Bash deployment script is a sequence of commands wrapped with control flow, error handling, and logging. The key mental shift: instead of thinking "I run this command, then that command," you think "I define a state transition — from 'code committed' to 'application live' — and the script enforces it.
Here's the core anatomy:
- Shebang (
#!/usr/bin/env bash) — tells the system which interpreter to use. - Strict mode (
set -euo pipefail) — makes the script fail fast instead of silently continuing after an error. - Functions for each deploy phase — smaller, testable commands.
- Variables for configuration — so the same script can deploy to staging or production just by changing an environment variable.
- Error handling traps and exit codes — so you know the exact point of failure.
- Logging — so you (or your CI system) can see what happened.
Keep this analogy in mind: your CI pipeline (GitHub Actions, Jenkins, etc.) is the orchestrator — it decides when to run. Your Bash script is the executor — it decides how.
How it works step by step
Let's trace what happens when you run ./deploy.sh production:
- Unpack arguments — the script parses the target environment (e.g.,
production,staging) and possibly extra flags. - Load configuration — reads environment variables (or a
.envfile) for server addresses, image names, SSH keys, etc. - Build phase — compiles the application, produces a JAR, Docker image, or static files.
- Test phase (optional) — runs a subset of tests to validate the artifact.
- Package phase — creates a tarball or Docker image, tags it with a version and timestamp.
- Transfer phase — copies the artifact to the target server (using
scp,rsync, or pushing to a registry). - Install/restart phase — unpacks the artifact, moves files into place, restarts the service.
- Smoke test — checks the health endpoint, verifies the version.
- Log/rollback — logs success details; if a step fails, triggers a rollback to the previous version.
Each step depends on the previous one succeeding. That's why set -e is so important: when a command fails, the script exits immediately, and the CI system sees a red status. No more "It looked like it worked but the server is actually down" moments.
Why Bash (and not just CI commands)
Bash is the universal glue. Your CI tool (GitHub Actions, GitLab CI, Jenkins) has its own syntax, but every one of them lets you call a normal shell script. By keeping your deploy logic in a Bash script, you:
- Test it locally — no need to push a commit to see if a step works.
- Reuse it across tools — the same script runs in GitHub Actions, cron, or a developer's machine.
- Read it in any editor — no vendor lock-in.
Hands-on walkthrough
Let's build a deployment script from scratch for a simple Node.js application. We'll deploy to a remote server over SSH, but the patterns apply to any stack.
1. Start with a minimal skeleton
Create a file called deploy.sh:
#!/usr/bin/env bash
# Strict mode: exit on error, undefined variable, or pipe failure
set -euo pipefail
# -- Configuration -----------------------------------------------
APP_NAME="myapp"
REMOTE_USER="ubuntu"
REMOTE_HOST="prod-server.example.com"
REMOTE_DIR="/var/www/myapp"
# -- Logging helpers ---------------------------------------------
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
# -- Main ---------------------------------------------------------
log "Deploying $APP_NAME"
# ... steps will go here
log "Deployment complete"
Make it executable:
chmod +x deploy.sh
Now run it: ./deploy.sh. You'll see the two log lines. Try removing the set -euo pipefail line and running a nonexistent command — the script will just skip errors. That's the difference between a reliable script and a time bomb.
2. Add build and test steps
build() {
log "Building $APP_NAME"
npm ci
npm run build
}
run_tests() {
log "Running tests"
npm test -- --runInBand
}
# In the main section:
build
run_tests
3. Package and transfer
package() {
log "Packaging $APP_NAME"
tar -czf "${APP_NAME}.tar.gz" build package.json
}
transfer() {
log "Uploading to $REMOTE_HOST"
scp "${APP_NAME}.tar.gz" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
}
4. Install on the remote server
install_remote() {
log "Installing on remote"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
cd ${REMOTE_DIR} &&
tar -xzf ${APP_NAME}.tar.gz &&
pm2 restart ${APP_NAME} || systemctl restart myapp
"
}
5. Smoke test
smoke_test() {
log "Smoke testing"
local health_endpoint="http://${REMOTE_HOST}/health"
if curl -sf "$health_endpoint"; then
log "Health check passed"
else
log "ERROR: Health check failed" >&2
exit 1
fi
}
Now put it all together in the main section and run the whole script. Here's the complete flow:
# full deploy.sh (excerpt)
build
run_tests
package
transfer
install_remote
smoke_test
log "Deployment to $REMOTE_HOST succeeded!"
Expected output on success:
[2025-04-08 10:15:30] Deploying myapp
[2025-04-08 10:15:32] Building myapp
... (npm output) ...
[2025-04-08 10:16:01] Running tests
... (test output) ...
[2025-04-08 10:16:45] Packaging myapp
[2025-04-08 10:16:46] Uploading to prod-server.example.com
[2025-04-08 10:17:02] Installing on remote
[2025-04-08 10:17:05] Smoke testing
[2025-04-08 10:17:06] Health check passed
[2025-04-08 10:17:06] Deployment to prod-server.example.com succeeded!
6. Add rollback (because things will fail)
ROLLBACK_VERSION="" # set in failure trap
rollback() {
log "Rolling back to $ROLLBACK_VERSION"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cd ${REMOTE_DIR} && \
tar -xzf previous.tgz && \
pm2 restart ${APP_NAME}"
}
trap 'rollback' ERR
Now your script can undo a bad deploy automatically.
Compare options / when to choose what
Bash is not the only way to write deployment scripts. Here's a quick comparison:
| Tool | Best for | Trade-offs |
|---|---|---|
| Bash | Small to medium projects, CI glue, quick ops tasks | Powerful but easy to get syntax wrong; no structured error handling by default |
| Ansible | Configuration management, multi-server orchestration, repeatable state | Overkill for a single script; requires YAML knowledge and a control node |
| Makefile | Build automation, combined with bash commands | Great for make deploy but not a universal language; needs make installed |
| Python/Node script | Complex logic, platform-independent | Heavier than Bash; one more runtime dependency |
CI-native steps (e.g., GitHub Actions run blocks) |
Simple workflows, easily visible in the pipeline UI | Code is stuck in the vendor's YAML; cannot reuse locally |
Recommendation: Start with a plain Bash script for your core deploy logic. It's portable, zero-dependency, and you can call it from any CI system. As your infrastructure grows into multiple servers or stateful configuration, consider Ansible — but keep the Bash script as the under-the-hood executor.
Troubleshooting & edge cases
Here are the most common issues you'll hit and how to fix them:
set -edoesn't catch a failing command — Incmd1 && cmd2 || cmd3, the||makes the whole expression return success. Avoid this pattern for critical steps, or use explicitifstatements withexit 1.- SSH prompts for a password — Use SSH keys and set
BatchMode=yesin your SSH command to fail fast instead of hanging. scpfails silently withfile not found— If the remote directory doesn't exist,scpmay error but the script might not stop. Usessh ... "mkdir -p $REMOTE_DIR"first.- Environment variables are empty — Always use
${VAR:?Error message}to crash with a helpful message if a variable isn't set. - Line endings are
\r\n— If you write the script on Windows,./deploy.shfails withbad interpreter. Convert to Unix line endings or usedos2unix. - Policy-based deployment safety — In a CI pipeline, you might want to add a manual approval step before the script runs. Bash can't manage that; your CI tool should.
Common mistakes to avoid (detailed below) further illustrate these edges.
What you learned & what's next
You've now seen how to write deployment scripts with Bash: from a mental model of state transitions to a fully functioning script with build, test, package, transfer, install, smoke test, and rollback. You can:
- Explain how a Bash deployment script structures your release process.
- Apply
set -euo pipefail, functions, and trap to build a resilient script. - Connect this skill to your CI/CD pipeline — the next lesson in this track will show you how to wrap this script in a GitHub Actions workflow, adding triggers, secrets, and approvals.
Practice by taking your own deployment and breaking it into the phases we covered. Write a minimal script that echoes each phase first, then build out the real commands. The script doesn't have to be perfect — it just has to be better than clicking through a web console.
Now, let's move on to the next lesson: Wrapping your deployment script in a CI/CD workflow.
Practice recap
Take your own application's deployment steps and write a minimal deploy.sh that echoes each phase (build, test, package, transfer, install, smoke). Run it to see the flow, then add real commands one phase at a time. In the next lesson, you'll wrap this script in a GitHub Actions workflow to trigger it automatically on push.
Common mistakes
- Forgetting
set -euo pipefail— your script keeps running after a failed command and may 'succeed' with an invalid deployment. - Hardcoding secrets like passwords or API keys in the script — always use environment variables and inject secrets via your CI tool.
- Not using
set -u— your script silently uses empty variables likeREMOTE_DIR=/and could delete the wrong directory. - Using Windows line endings (
\r\n) — the shebang line fails withbad interpreter: No such file or directory.
Variations
- Use
rsyncinstead ofscpfor faster incremental transfers and easier exclusion of source files. - Wrap your Bash script in a Makefile —
make deploycalls the same logic, giving you tab-completion and a familiar interface. - For multi-server deployments, adapt the Bash script to loop over hostnames from an environment variable — a lightweight alternative to Ansible.
Real-world use cases
- Deploying a Node.js app to a single VPS after CI runs tests.
- Rolling out a Docker image to a production swarm via SSH.
- Running a zero-downtime deploy of a static site via
rsyncbehind a load balancer.
Key takeaways
- A deployment script is a sequence of state transitions with logging and rollback.
set -euo pipefailturns silent failures into loud, early exits.- Break your script into functions: build, test, package, transfer, install, smoke test.
- Always include a smoke test and rollback path to handle failed deployments gracefully.
- Bash scripts are portable across CI systems — write once, run anywhere.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.