Architecture
The pipeline lives in references/scripts/ under the repo. It uses a module-loader pattern — lib/index.sh sources every .sh module in sorted order, guarded against double-includes. The entry point (deploy.sh) sources the loader, then calls into whichever modules it needs.
references/scripts/
├── deploy.sh # Entry point / orchestrator
└── lib/
├── index.sh # Auto-loader (sources all *.sh in lib/)
├── helpers.sh # die(), dry-run, pushd/popd, timestamps
├── docker.sh # Build & push strategy per environment
├── kube.sh # kubectl apply (local or remote via SSH)
├── systemd.sh # SSH + systemd image-bump for bare-metal
├── version.sh # .env / deploy.yaml / .version snapshot
├── git.sh # Git add + commit
└── help.sh # Usage text
The loader pattern means you never need to manually source individual modules — adding a new lib/*.sh file automatically makes its functions available.
How the loader works (lib/index.sh)
_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
while IFS= read -r -d '' mod; do
case "$(basename "$mod")" in index.sh) continue ;; esac
. "$mod"
done < <(find "$_LIB_DIR" -maxdepth 1 -type f -name '*.sh' -print0 | sort -z)
It finds its own directory (handles being sourced from anywhere), then globs every .sh file in the same folder — except itself — and sources them in deterministic alphabetical order. A __LIB_INDEX_SH_SOURCED guard variable prevents double-loading if index.sh is sourced twice in the same shell.
Environments & Deployment Paths
The pipeline supports three environments, each with a different rollout strategy:
| Environment | Dockerfile | Version bump | Rollout strategy |
|---|---|---|---|
local | Dockerfile.local | .env + kubernetes/deploy.yaml + .version | kubectl apply on local cluster |
staging | Dockerfile.staging | .env + kubernetes/deploy.yaml + .version | SCP manifests to remote → kubectl apply via SSH |
production | Dockerfile.prod | .env only + .version | SSH → edit /etc/default/<svc> → systemctl restart |
Staging uses Kubernetes. Manifests are SCP'd to a remote control node and applied with kubectl apply -k (Kustomize).
Production uses bare-metal VMs. The pipeline SSHs directly into each host, atomically bumps the IMAGE= line in /etc/default/<service>, then runs systemctl daemon-reload + restart.
Each service directory must contain the corresponding Dockerfile.* or the build fails immediately. You define which directories constitute "services" inside build_all_images().
Quick Reference — All Commands
# Basic usage pattern
./deploy.sh <version> <env> [options]
# Local development cluster
./deploy.sh 1.4.0 local
# Staging (Kubernetes via SSH)
./deploy.sh 1.4.0 staging
# Production (bare-metal via systemd)
./deploy.sh 1.4.0 production
# Dry-run: print what would happen, make no changes
./deploy.sh --dry-run 1.4.0 staging
DRY_RUN=1 ./deploy.sh 1.4.0 local
# Show help
./deploy.sh --help
Positional arguments:
version— Semver tag used as the Docker image tag (e.g.1.4.0)env— One oflocal,staging,production
Options:
-n,--dry-run— Echo commands instead of executing them-h,--help,help— Print usage and exit
Environment variable:
DRY_RUN=1— Same effect as--dry-run
Deploy to Kubernetes (local / staging)
Both local and staging follow the same pattern, differing only in whether kubectl runs on the local machine or over SSH.
Flow
build_all_images → push_all_images (staging only)
│
▼
update_envs → bumps version in .env files
→ bumps image tags in deploy.yaml
→ writes .version snapshot
│
▼
exec_kube → copies ./kubernetes/* to the target
→ kubectl apply -k (Kustomize overlay)
The Kubernetes module (lib/kube.sh)
exec_kube() {
local remote_host="$1"
[ -z "$remote_host" ] && die "Remote host not specified."
if [ "$remote_host" = "local" ]; then
maybe rm -rf ~/kubernetes/<project>/*
maybe cp -r ./kubernetes/* ~/kubernetes/<project>/
maybe kubectl apply -k ~/kubernetes/<project> --force
else
maybe ssh "$remote_host" rm -rf ~/kubernetes/<project>/*
maybe scp -r -v ./kubernetes/* "$remote_host":~/kubernetes/<project>/
maybe ssh "$remote_host" 'kubectl apply -k ~/kubernetes/<project> --force'
fi
}
Key points:
- Uses Kustomize (
-k) — the./kubernetes/directory should contain akustomization.yaml - For local: copies manifests to the target path and applies directly
- For remote: SCPs manifests, then SSHs and applies
--forceflag ensures resource conflicts are resolved- The target path (
~/kubernetes/<project>/) is a project-specific value — change it to match your layout
Example: staging deploy
./deploy.sh 1.5.0 staging
What happens:
- Builds each service with
Dockerfile.staging - Pushes images to your registry
- Updates version strings in all
.envfiles - Updates image tags in
kubernetes/deploy.yaml - Writes
.versionmetadata file - SCPs manifests to the staging control host
- Runs
kubectl apply -kon the staging cluster - Git commits everything
Deploy to SSH Hosts (production)
Production bypasses Kubernetes entirely — it uses SSH + systemd to update bare-metal VMs running services directly.
Flow
build_all_images → push_all_images
│
▼
update_envs → bumps .env files (skips kubernetes/deploy.yaml)
│
▼
systemd_update_remote_host → SSHs to each production host
→ edits IMAGE= in /etc/default/<service>
→ systemctl daemon-reload
→ systemctl restart <service>
│
▼
git_commit
The systemd module (lib/systemd.sh)
For each service listed in systemd_update_remote_host() on each production host:
- SSH into the host with
BatchMode=yes(key-based auth) - Check
/etc/default/<service>exists - Backup the env file:
sudo cp -a "$env_file" "${env_file}.bak" - Find the first
IMAGE=line - Replace the tag portion (everything after
:) with the new version - Write the update via
sudo tee+ atomicmv - Systemctl sequence:
sudo systemctl daemon-reloadsudo systemctl restart "${svc}.service"sudo systemctl --no-pager --full status "${svc}.service"
The full update logic runs remotely via a heredoc — no scripts need to be pre-installed on the target host:
maybye ssh "${SSH_OPTS[@]}" "$host" bash -s -- "$svc" "$new_tag" <<'REMOTE'
set -euo pipefail
svc="$1"
new_tag="$2"
# ... bump IMAGE=, daemon-reload, restart ...
REMOTE
SSH options used:
BatchMode=yes— never prompt for password (fails if key auth isn't set up)StrictHostKeyChecking=accept-new— auto-accept first connection, fail on host-key mismatchConnectTimeout=8— fail fast if unreachable
Customizing hosts and services
The hostnames and service names are hard-coded near the top of deploy.sh and inside systemd.sh. Adapt these for your own project:
# deploy.sh — remote hosts
STAGING_HOST="your-k8s-control-node"
PRODUCTION_HOST_1="your-vm-1"
PRODUCTION_HOST_2="your-vm-2"
# systemd.sh — service names (must match /etc/default/<name>)
for svc in your-api your-frontend your-worker; do
_systemd_remote_update_one "$host" "$svc" "$new_tag"
done
Example: production deploy
./deploy.sh 2.0.0 production
What happens:
- Builds all images with
Dockerfile.prod - Pushes to registry
- Updates
.envfiles (skipskubernetes/deploy.yamlsinceUPDATE_KUBE=false) - Writes
.version - For each production host:
- SSHs in, bumps
IMAGE=in/etc/default/<service> - Restarts the service via systemctl
- Repeats for every service
- SSHs in, bumps
- Git commits
The Dry-Run System
Every destructive or state-changing operation is wrapped in maybe:
maybe() {
if is_dry_run; then
echo "[DRY-RUN] $*"
else
"$@"
fi
}
This means --dry-run gives you a complete trace of every command that would execute — including docker build, docker push, kubectl apply, ssh commands, sed edits, and git commit — without actually touching anything.
./deploy.sh --dry-run 1.4.0 staging
# Output:
# [DRY-RUN] docker build -f Dockerfile.staging . -t <registry>/<image>:1.4.0
# [DRY-RUN] docker push <registry>/<image>:1.4.0
# ...etc...
State Files Written
.env / .env.* (any depth-2 file matching)
NEXT_PUBLIC_APP_VERSION=1.4.0
The env-var name (NEXT_PUBLIC_APP_VERSION) is Next.js-specific — change the grep/sed patterns in version.sh to match your framework.
kubernetes/deploy.yaml (local & staging only)
All image tags :X.Y.Z are replaced with the new version via regex.
.version (repo root — written on every run)
CURRENT_VERSION=1.4.0
LAST_DEPLOY_ENV=staging
LAST_DEPLOY_AT=2026-08-03T14:03:11+04:00
LAST_DEPLOY_USER=pierre
GIT_BRANCH=main
GIT_COMMIT_SHA=a1b2c3d
This file is sourced on the next run to provide the OLD_VERSION baseline.
Docker Build Strategy
Each environment maps to a specific Dockerfile:
dockerfile_for_env() {
case "$1" in
local) echo "Dockerfile.local" ;;
staging) echo "Dockerfile.staging" ;;
production) echo "Dockerfile.prod" ;;
*) die "Unknown env '$1'" ;;
esac
}
The expected Dockerfiles per service directory — defined in build_all_images():
| Service directory | Required Dockerfiles |
|---|---|
api/ | Dockerfile.local, Dockerfile.staging, Dockerfile.prod |
www/ | Dockerfile.local, Dockerfile.staging, Dockerfile.prod |
worker/ | Dockerfile.local, Dockerfile.staging, Dockerfile.prod |
Image naming convention (customize user and image names in build_all_images()):
<registry-user>/<project>-api:<version>
<registry-user>/<project>-frontend:<version>
<registry-user>/<project>-worker:<version>
Complete File Reference
deploy.sh
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# load lib
# shellcheck disable=SC1091
. "$REPO_ROOT/lib/index.sh"
# --- REMOTE HOSTS (customize per project) ---
STAGING_HOST="your-k8s-control-node"
PRODUCTION_HOST_1="your-vm-1"
PRODUCTION_HOST_2="your-vm-2"
parse_args() {
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
-n | --dry-run)
DRY_RUN=1
shift
;;
-h | --help | help)
print_help
exit 0
;;
-*)
die "Unknown flag: $1"
;;
*)
break
;;
esac
done
NEW_VERSION="${1:-}"
shift || true
ENV_NAME="${1:-}"
shift || true
[ -z "${NEW_VERSION:-}" ] && die "Version tag not specified."
[ -z "${ENV_NAME:-}" ] && die "Environment not specified."
case "$ENV_NAME" in local | staging | production) ;; *) die "Invalid env '$ENV_NAME'. Allowed: local|staging|production." ;; esac
if [ -n "${1:-}" ]; then
die "Unknown argument: $1"
fi
}
run() {
parse_args "$@"
if [ -f .version ]; then
. .version
OLD_VERSION="${CURRENT_VERSION:-0.0.0}"
else
OLD_VERSION="0.0.0"
fi
echo "Old Version: $OLD_VERSION"
echo "New Version: $NEW_VERSION"
echo "Environment: $ENV_NAME"
echo "DRY_RUN=${DRY_RUN}"
UPDATE_KUBE="true"
[ "$ENV_NAME" = "production" ] && UPDATE_KUBE="false"
update_envs "$OLD_VERSION" "$NEW_VERSION" "$ENV_NAME" "$UPDATE_KUBE"
if [ "$ENV_NAME" = "local" ]; then
echo "==> Local build selected"
build_all_images "$NEW_VERSION" "$ENV_NAME"
exec_kube "local"
echo "Local deploy complete."
fi
if [ "$ENV_NAME" = "staging" ]; then
echo "==> Staging build selected"
build_all_images "$NEW_VERSION" "$ENV_NAME"
push_all_images "$NEW_VERSION"
exec_kube "$STAGING_HOST"
echo "Staging deploy complete."
fi
if [ "$ENV_NAME" = "production" ]; then
echo "==> Prod build selected"
build_all_images "$NEW_VERSION" "$ENV_NAME"
push_all_images "$NEW_VERSION"
systemd_update_remote_host "$PRODUCTION_HOST_1" "$NEW_VERSION"
systemd_update_remote_host "$PRODUCTION_HOST_2" "$NEW_VERSION"
echo "Production deploy complete."
fi
git_commit "$NEW_VERSION" "$ENV_NAME"
echo "Deployment complete"
}
run "$@"
lib/index.sh
#!/usr/bin/env bash
# Loads all *.sh modules in this directory (except itself), once.
if [ -n "${__LIB_INDEX_SH_SOURCED:-}" ]; then
return 0 2>/dev/null || exit 0
fi
readonly __LIB_INDEX_SH_SOURCED=1
_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
while IFS= read -r -d '' mod; do
case "$(basename "$mod")" in
index.sh) continue ;;
esac
# shellcheck source=/dev/null
. "$mod"
done < <(find "$_LIB_DIR" -maxdepth 1 -type f -name '*.sh' -print0 | sort -z)
lib/helpers.sh
#!/usr/bin/env bash
: "${TOP_PID:=$$}"
die() {
local msg="${1:-"Unknown error"}"
echo "[ERROR] $msg" >&2
if [ -n "${USAGE:-}" ]; then echo "$USAGE" >&2; fi
if [ -n "${TOP_PID:-}" ] && kill -0 "$TOP_PID" 2>/dev/null; then
kill -s TERM "$TOP_PID"
else
exit 1
fi
}
pushd_quiet() {
local d="$1"
echo
echo "Changing working directory to ./$d..."
pushd "$d" &>/dev/null || die "Failed to enter $d."
}
popd_quiet() {
popd &>/dev/null || die "Failed to return to previous directory."
}
is_dry_run() {
case "${DRY_RUN:-0}" in
1 | true | TRUE | on | ON | yes | YES) return 0 ;;
*) return 1 ;;
esac
}
maybe() {
if is_dry_run; then
echo "[DRY-RUN] $*"
else
"$@"
fi
}
now_ts() {
date -Iseconds
}
lib/docker.sh
#!/usr/bin/env bash
dockerfile_for_env() {
case "$1" in
local) echo "Dockerfile.local" ;;
staging) echo "Dockerfile.staging" ;;
production) echo "Dockerfile.prod" ;;
*) die "Unknown env '$1' for Dockerfile mapping (expected: local|staging|production)" ;;
esac
}
ensure_dockerfile() {
local df="$1"
if [ ! -f "$df" ]; then
echo "[ERROR] Required Dockerfile not found: $df" >&2
echo " CWD: $(pwd)" >&2
echo " Available Dockerfile* here:" >&2
ls -1 Dockerfile* 2>/dev/null || true
die "Missing Dockerfile: $df"
fi
}
docker_build() {
local repo="$1" env="$2"
[ -z "$repo" ] && die "Repo required for docker build"
[ -z "$env" ] && die "Env required for docker build"
local df
df="$(dockerfile_for_env "$env")"
ensure_dockerfile "$df"
echo "Building image: $repo using $df"
maybe docker build -f "$df" . -t "$repo"
}
docker_push() {
local repo="$1"
[ -z "$repo" ] && die "Repo required for docker push"
maybe docker push "$repo"
}
# --- Customize: add/remove service directories and image names ---
build_all_images() {
local version="$1" env="$2"
[ -z "$version" ] && die "Version tag not specified."
[ -z "$env" ] && die "Env tag not specified."
local user="your-registry-user"
pushd_quiet "api"
docker_build "$user/<project>-api:$version" "$env"
popd_quiet
pushd_quiet "www"
docker_build "$user/<project>-frontend:$version" "$env"
popd_quiet
pushd_quiet "worker"
docker_build "$user/<project>-worker:$version" "$env"
popd_quiet
}
push_all_images() {
local version="$1"
[ -z "$version" ] && die "Version tag not specified."
local user="your-registry-user"
maybe docker_push "$user/<project>-api:$version"
maybe docker_push "$user/<project>-frontend:$version"
maybe docker_push "$user/<project>-worker:$version"
}
lib/kube.sh
#!/bin/bash
exec_kube() {
local remote_host="$1"
[ -z "$remote_host" ] && die "Remote host not specified."
if [ "$remote_host" = "local" ]; then
maybe rm -rf ~/kubernetes/<project>/*
maybe cp -r ./kubernetes/* ~/kubernetes/<project>/
maybe kubectl apply -k ~/kubernetes/<project> --force
else
maybe ssh "$remote_host" rm -rf ~/kubernetes/<project>/*
maybe scp -r -v ./kubernetes/* "$remote_host":~/kubernetes/<project>/
maybe ssh "$remote_host" 'kubectl apply -k ~/kubernetes/<project> --force'
fi
}
lib/systemd.sh
#!/bin/bash
SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8)
_systemd_remote_update_one() {
local host="$1" svc="$2" new_tag="$3"
echo "INFO: running remote service update for ${svc}.service on HOST: ${host}"
maybe ssh "${SSH_OPTS[@]}" "$host" bash -s -- "$svc" "$new_tag" <<'REMOTE'
set -euo pipefail
svc="$1"
new_tag="$2"
env_file="/etc/default/${svc}"
if [ ! -f "$env_file" ]; then
echo "WARN: ${env_file} not found, skipping" >&2
exit 0
fi
echo "INFO: making backup ${env_file}.bak"
sudo cp -a "$env_file" "${env_file}.bak"
line="$(grep -m1 -E '^IMAGE=' "$env_file" || true)"
if [ -z "$line" ]; then
echo "WARN: No IMAGE= in ${env_file}; skipping"
else
image="${line#IMAGE=}"
if [[ "$image" == *:* ]]; then
repo="${image%:*}"
new_image="${repo}:${new_tag}"
else
new_image="${image}:${new_tag}"
fi
if [ "$image" = "$new_image" ]; then
echo "INFO: ${env_file} already at ${new_image}"
else
echo "UPDATE: ${env_file} IMAGE=$image -> IMAGE=$new_image"
sudo awk -v newimg="IMAGE=${new_image}" '
BEGIN { done=0 }
{
if (!done && $0 ~ /^IMAGE=/) { print newimg; done=1 }
else { print }
}
' "$env_file" | sudo tee "${env_file}.tmp" > /dev/null
sudo mv "${env_file}.tmp" "$env_file"
fi
fi
sudo systemctl daemon-reload
sudo systemctl restart "${svc}.service"
sudo systemctl --no-pager --full status "${svc}.service" -n 0 || true
echo "INFO: service reloaded ${svc}.service"
REMOTE
}
# --- Customize: list your systemd service names here ---
systemd_update_remote_host() {
local host="$1" new_tag="$2"
for svc in <project>-api <project>-frontend <project>-worker; do
_systemd_remote_update_one "$host" "$svc" "$new_tag"
done
}
lib/version.sh
#!/usr/bin/env bash
_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown"; }
_git_sha() { git rev-parse --short HEAD 2>/dev/null || echo "unknown"; }
_user_name() { id -un 2>/dev/null || whoami 2>/dev/null || echo "unknown"; }
_write_version_file() {
local new_version="$1" env="$2"
local ts user branch sha
ts="$(now_ts)"
user="$(_user_name)"
branch="$(_git_branch)"
sha="$(_git_sha)"
echo "Writing .version with:"
echo " CURRENT_VERSION=$new_version"
echo " LAST_DEPLOY_ENV=$env"
echo " LAST_DEPLOY_AT=$ts"
echo " LAST_DEPLOY_USER=$user"
echo " GIT_BRANCH=$branch"
echo " GIT_COMMIT_SHA=$sha"
if is_dry_run; then
echo "[DRY-RUN] > .version (not writing)"
else
cat >.version <<EOF
CURRENT_VERSION=$new_version
LAST_DEPLOY_ENV=$env
LAST_DEPLOY_AT=$ts
LAST_DEPLOY_USER=$user
GIT_BRANCH=$branch
GIT_COMMIT_SHA=$sha
EOF
fi
}
update_envs() {
echo "$@"
local old_version="$1" new_version="$2" env="$3" update_kube="${4:-false}"
[ -z "$new_version" ] && die "Usage: update_envs <old_version> <new_version> <env> [update_kube]"
echo "Updating .env files with APP_VERSION=$new_version..."
while IFS= read -r -d '' env_file; do
if grep -q "^APP_VERSION=" "$env_file"; then
maybe sed -i "s/^APP_VERSION=.*/APP_VERSION=$new_version/" "$env_file"
echo " - updated: $env_file"
fi
done < <(find . -mindepth 2 -maxdepth 2 -type f \( -name ".env" -o -name ".env.*" \) -print0)
if [ "$update_kube" = "true" ]; then
if [ -f kubernetes/deploy.yaml ]; then
echo "Updating kubernetes/deploy.yaml image tags: :$old_version -> :$new_version ..."
maybe sed -E -i "s/:[0-9]+\.[0-9]+\.[0-9]+/:${new_version}/g" kubernetes/deploy.yaml
else
echo "[WARN] kubernetes/deploy.yaml not found; skipping."
fi
fi
_write_version_file "$new_version" "$env"
}
lib/git.sh
#!/usr/bin/env bash
git_commit() {
version="$1"
env="$2"
echo "[INFO]: Running git commit for version $version to $env"
maybe git add .
maybe git commit -m "deploy: version $version to $env"
}
lib/help.sh
#!/usr/bin/env bash
print_help() {
cat <<'EOF'
Deploy Pipeline
USAGE
./deploy.sh <version> <env: local|staging|production> [options]
POSITIONAL ARGUMENTS
version Docker image tag to build/push/use (e.g. 1.4.0)
env One of:
- local : build all images with Dockerfile.local,
update .env files, bump kubernetes/deploy.yaml,
kubectl apply locally
- staging : build & push images (Dockerfile.staging),
update .env files, bump kubernetes/deploy.yaml,
kubectl apply on staging host via SSH
- production : build & push images (Dockerfile.prod),
update .env files, update bare-metal hosts
via SSH + systemd
OPTIONS
-n, --dry-run Print what would happen without making any changes
-h, --help, help Show this help and exit
BEHAVIOR
- Dockerfiles (required per service dir):
local -> Dockerfile.local
staging -> Dockerfile.staging
production -> Dockerfile.prod
The build will FAIL if the expected Dockerfile is missing.
- Files/State changed:
- .env / .env.* : APP_VERSION=<version> is updated (if present)
- kubernetes/deploy.yaml : image tags :<old> -> :<version> (local & staging only)
- .version : snapshot of the last run
- Remote hosts are hard-coded in the script — edit to match your infrastructure.
ENV VARS
DRY_RUN=1 Same effect as --dry-run
EXAMPLES
./deploy.sh 1.4.0 local
./deploy.sh 1.4.0 staging
./deploy.sh 1.4.0 production
./deploy.sh --dry-run 1.4.0 staging
DRY_RUN=1 ./deploy.sh 1.4.0 local
EXIT CODES
0 success
1+ failure (missing args, missing Dockerfile, command error, etc.)
EOF
}
Key Design Decisions
Robust script-relative sourcing
The deploy script uses BASH_SOURCE to resolve its own directory, so it works no matter where you invoke it from:
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
. "$REPO_ROOT/lib/index.sh"
No config files — the script IS the config
Remote hosts, Docker registry users, and service names are all hard-coded directly in the scripts. This is intentional for a single-project monorepo — there's no indirection to chase when debugging. To adapt this for your project, edit the values in deploy.sh (hosts), docker.sh (images), and systemd.sh (service names).
Atomic systemd env updates
The systemd module backs up /etc/default/<svc> before touching it, writes to a .tmp file, then mvs it into place. If the SSH connection drops mid-write, the original file is intact and the service keeps running on the old image.
Everything is dry-run-aware
Every docker build, docker push, kubectl apply, ssh, scp, sed, and git commit goes through maybe(). A single --dry-run flag gives you complete visibility into what the pipeline will do before it touches anything.
