jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Bash & Shell Scripting · Part 10 — Real-World Automation & Best Practices

Capstone: getopts, usage(), script structure, logging, env config, and a production-grade backup script with traps and validation — plus best practices and when NOT to use bash. Bilingual exercises.

This is Part 10 of a 10-part series — the capstone that ties everything together into scripts you would actually ship {Đây là Phần 10 của series 10 bàibài kết gom mọi thứ thành script bạn thực sự đưa vào production}. Parts 1–9 gave you the language: variables, conditionals, loops, functions, I/O, text tools, and error handling {Phần 1–9 đã cho bạn ngôn ngữ: biến, điều kiện, vòng lặp, function, I/O, công cụ text, và xử lý lỗi}. Now we assemble those pieces into a real automation script with argument parsing, logging, configuration, validation, and cleanup {Giờ ta lắp các mảnh thành script automation thật có parse tham số, logging, cấu hình, kiểm tra, và dọn dẹp}.

By the end of this part you will have a mental template — and a full working example — for any script you write at work {Hết phần này bạn sẽ có một template trong đầu — và một ví dụ chạy được — cho mọi script bạn viết ở công việc}.


The production script pipeline {Pipeline script production}

A maintainable automation script follows the same four beats every time {Một script automation dễ bảo trì luôn theo bốn nhịp giống nhau}:

  1. Parse args — understand what the user asked for {Parse tham số — hiểu người dùng muốn gì}.
  2. Validate — check inputs, dependencies, and permissions before touching data {Validate — kiểm tra input, dependency, và quyền trước khi đụng dữ liệu}.
  3. Do work — the actual task (backup, deploy, sync, etc.) {Làm việc — tác vụ thật (backup, deploy, sync, v.v.)}.
  4. Log + exit — report what happened and return a meaningful status code {Log + exit — báo cáo chuyện gì xảy ra và trả status code có ý nghĩa}.
parse args getopts validate inputs + deps do work main logic log + exit status code trap cleanup EXIT wraps the whole run · ShellCheck guards quality
parse args → validate → do work → log+exit — wrap the whole run with strict mode and trap cleanup EXIT

Everything else — functions, constants, env defaults — exists to make those four steps readable and safe {Mọi thứ khác — function, hằng số, default từ env — tồn tại để bốn bước đó dễ đọc và an toàn}.


Parsing options with getopts {Parse tùy chọn với getopts}

getopts is bash’s built-in parser for short flags like -v, -h, -o file.txt {getopts là parser tích hợp của bash cho flag ngắn như -v, -h, -o file.txt}. It is POSIX-portable within bash and far less error-prone than hand-rolling a while/case loop {Nó portable trong bash và ít lỗi hơn nhiều so với tự viết vòng while/case}.

The option string {Chuỗi tùy chọn}

The second argument to getopts is an option string {Tham số thứ hai của getoptschuỗi tùy chọn}:

  • A letter alone (v) means the flag takes no argument: -v {Chữ cái đơn (v) nghĩa là flag không nhận tham số: -v}.
  • A letter followed by : (o:) means the flag requires an argument: -o file.txt {Chữ cái theo sau bởi : (o:) nghĩa là flag bắt buộc có tham số: -o file.txt}.
  • A leading : silences getopts’ own error messages so you can print your own usage {: ở đầu tắt thông báo lỗi mặc định của getopts để bạn in usage riêng}.
#!/usr/bin/env bash
set -euo pipefail

VERBOSE=0
OUTPUT=""

usage() {
  cat >&2 <<'EOF'
Usage: mytool [-h] [-v] [-o FILE] [ARGS...]

  -h    show this help
  -v    verbose output
  -o    write output to FILE (required for some modes)
EOF
}

while getopts ":hvo:" opt; do
  case "$opt" in
    h) usage; exit 0 ;;
    v) VERBOSE=1 ;;
    o) OUTPUT="$OPTARG" ;;
    \?) echo "Unknown option: -$OPTARG" >&2; usage; exit 2 ;;
    :)  echo "Option -$OPTARG requires an argument." >&2; usage; exit 2 ;;
  esac
done
shift $((OPTIND - 1))   # remaining positional args: "$@"

echo "verbose=$VERBOSE output=$OUTPUT remaining=$*"

Key variables getopts sets for you {Biến getopts đặt sẵn cho bạn}:

Variable {Biến}Meaning {Ý nghĩa}
$optThe option letter just parsed {Chữ cái vừa parse}
$OPTARGThe argument for flags that take one (-o) {Tham số cho flag có đối số (-o)}
$OPTINDIndex of the next positional arg; use shift $((OPTIND - 1)) after the loop {Chỉ số arg vị trí tiếp theo; dùng shift $((OPTIND - 1)) sau vòng lặp}

Limitation {Giới hạn}: getopts handles short flags only (-abc is three flags; -o file works). For long options (--verbose, --output=file) use getopt from GNU coreutils or a hand-written parser — or switch to a language with a real CLI library {getopts chỉ xử lý flag ngắn (-abc là ba flag; -o file hoạt động). Với option dài (--verbose, --output=file) dùng getopt của GNU coreutils, parser tự viết — hoặc chuyển sang ngôn ngữ có thư viện CLI thật}.


Building usage() / help {Xây usage() / help}

Every script that accepts flags should ship a usage function printed to stderr {Mọi script nhận flag nên có function usage in ra stderr}. That way normal output on stdout stays clean for piping {Như vậy output thường trên stdout vẫn sạch để pipe}.

Patterns that work well {Pattern hiệu quả}:

  • Use a heredoc (<<'EOF') so you do not accidentally expand $variables in the help text {Dùng heredoc (<<'EOF') để không vô tình expand $variables trong help}.
  • Call usage from -h, from unknown-flag handlers, and from validation failures {Gọi usage từ -h, từ handler flag lạ, và từ lỗi validate}.
  • Exit 0 for deliberate help (-h), exit 2 (or your project’s convention) for misuse {Thoát 0 khi help cố ý (-h), thoát 2 (hoặc quy ước dự án) khi dùng sai}.
usage() {
  cat >&2 <<EOF
$(basename "$0") — backup a directory to a tarball

Usage: $(basename "$0") [-hvn] [-o DIR] SOURCE

  -h    show help
  -v    verbose logging
  -n    dry-run (show commands, do not execute)
  -o    output directory (default: \$BACKUP_DIR or ./backups)

Environment:
  BACKUP_DIR    default output directory
  LOG_LEVEL     info | warn | error (default: info)

Exit codes:
  0  success
  1  runtime failure (copy, tar, etc.)
  2  usage / validation error
EOF
}

Note \$BACKUP_DIR in the heredoc — escaped so the help shows the literal name, not the variable’s value {Chú ý \$BACKUP_DIR trong heredoc — escape để help hiện tên literal, không phải giá trị biến}.


Structuring a real script {Cấu trúc script thật}

Scattered top-level commands become unmaintainable after ~50 lines {Lệnh rải rác ở top-level sẽ khó bảo trì sau ~50 dòng}. Production scripts follow a skeleton {Script production theo một khung xương}:

#!/usr/bin/env bash
# strict mode — Part 9 covered each flag
set -euo pipefail

# ── constants ──────────────────────────────────────
readonly SCRIPT_NAME="$(basename "$0")"
readonly DEFAULT_BACKUP_DIR="${BACKUP_DIR:-./backups}"

# ── globals set by getopts / env ───────────────────
VERBOSE=0
DRY_RUN=0

# ── logging ────────────────────────────────────────
log()   { ... }
info()  { ... }
warn()  { ... }
error() { ... }

# ── helpers ────────────────────────────────────────
usage()       { ... }
require_cmd() { ... }
cleanup()     { ... }

# ── core logic ─────────────────────────────────────
validate_inputs() { ... }
run_backup()      { ... }

# ── entry point ────────────────────────────────────
main() {
  parse_args "$@"
  validate_inputs
  run_backup
}

main "$@"

Why main "$@" at the bottom? {Vì sao main "$@" ở cuối?}

  • The entire script body runs inside main, so a premature return does not accidentally skip cleanup setup {Toàn bộ script chạy trong main, nên return sớm không vô tình bỏ qua setup cleanup}.
  • You can source the file in tests and call individual functions without executing main {Bạn có thể source file trong test và gọi từng function mà không chạy main}.
  • "$@" preserves argument quoting — never use $* for forwarding user input {"$@" giữ quoting tham số — không bao giờ dùng $* để chuyển input người dùng}.

Logging helpers — stderr with timestamps {Helper logging — stderr kèm timestamp}

Scripts should log diagnostics to stderr and reserve stdout for data the user might pipe {Script nên log chẩn đoán ra stderr và để stdout cho dữ liệu người dùng có thể pipe}. A tiny logging layer keeps messages consistent {Một lớp logging nhỏ giữ thông điệp nhất quán}:

_log_level="${LOG_LEVEL:-info}"   # info | warn | error

log() {
  # usage: log LEVEL "message"
  local level="$1"
  shift
  printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$level" "$*" >&2
}

info()  { log INFO  "$@"; }
warn()  { log WARN  "$@"; }
error() { log ERROR "$@"; }

# Optional: suppress info when quiet
info() {
  [[ "$VERBOSE" -eq 1 || "$_log_level" == "info" ]] && log INFO "$@" || true
}

Redirecting to >&2 is non-negotiable for tools that emit machine-readable stdout {Redirect >&2 là bắt buộc với tool xuất stdout dạng máy đọc được}. Timestamps matter when you grep logs hours later in CI {Timestamp quan trọng khi bạn grep log vài giờ sau trong CI}.


Configuration via environment variables {Cấu hình qua biến môi trường}

Hard-coding paths and secrets in scripts is a maintenance and security trap {Hard-code path và secret trong script là bẫy bảo trì và bảo mật}. Use environment variables with defaults {Dùng biến môi trường kèm default}:

# ${VAR:-default}  — use default if unset or empty
# ${VAR:-}         — empty default (explicit "no value")
readonly BACKUP_DIR="${BACKUP_DIR:-./backups}"
readonly RETENTION_DAYS="${RETENTION_DAYS:-7}"
readonly COMPRESS="${COMPRESS:-gzip}"   # gzip | none

Document every env var in usage() and in a comment block at the top of the file {Ghi mọi biến env trong usage() và trong block comment đầu file}. For secrets (API tokens, passwords), never put them in the script — read from env or a secrets manager and fail fast if missing {Với secret (token API, mật khẩu), không bao giờ để trong script — đọc từ env hoặc secrets manager và fail nhanh nếu thiếu}.

if [[ -z "${DEPLOY_TOKEN:-}" ]]; then
  error "DEPLOY_TOKEN is not set"
  exit 2
fi

Complete example: backup-dir.sh {Ví dụ đầy đủ: backup-dir.sh}

Here is a production-grade backup script that uses every technique from this series {Đây là script backup mức production dùng mọi kỹ thuật trong series}. Read it top to bottom before copying pieces into your own projects {Đọc từ trên xuống trước khi copy từng phần vào dự án của bạn}.

#!/usr/bin/env bash
# backup-dir.sh — tarball a source directory with validation, logging, and cleanup
#
# Environment:
#   BACKUP_DIR       output directory (default: ./backups)
#   RETENTION_DAYS   delete archives older than N days (default: 7, 0 = skip)
#   LOG_LEVEL        info | warn | error
#
# Exit codes:
#   0  success
#   1  runtime error (tar, disk, etc.)
#   2  usage / validation error

set -euo pipefail

# ── constants ──────────────────────────────────────
readonly SCRIPT_NAME="$(basename "$0")"
readonly DEFAULT_BACKUP_DIR="${BACKUP_DIR:-./backups}"
readonly RETENTION_DAYS="${RETENTION_DAYS:-7}"

# ── runtime state ──────────────────────────────────
VERBOSE=0
DRY_RUN=0
OUTPUT_DIR="$DEFAULT_BACKUP_DIR"
SOURCE=""
TEMP_LIST=""

# ── logging ────────────────────────────────────────
log() {
  local level="$1"
  shift
  printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$level" "$*" >&2
}
info()  { [[ "$VERBOSE" -eq 1 ]] && log INFO "$@" || log INFO "$@"; }
warn()  { log WARN "$@"; }
error() { log ERROR "$@"; }

# ── usage ──────────────────────────────────────────
usage() {
  cat >&2 <<EOF
$SCRIPT_NAME — create a timestamped tarball backup of a directory

Usage: $SCRIPT_NAME [-hvn] [-o DIR] SOURCE

  -h    show this help
  -v    verbose (log every step)
  -n    dry-run (print commands, do not execute)
  -o    output directory (default: \$BACKUP_DIR or ./backups)

Environment:
  BACKUP_DIR       default -o value
  RETENTION_DAYS   prune archives older than N days (0 = disable)

Examples:
  $SCRIPT_NAME -v ./myapp
  BACKUP_DIR=/mnt/backups $SCRIPT_NAME -o /mnt/backups ./myapp
EOF
}

# ── helpers ────────────────────────────────────────
require_cmd() {
  local cmd="$1"
  if ! command -v "$cmd" >/dev/null 2>&1; then
    error "required command not found: $cmd"
    exit 2
  fi
}

run() {
  # run [cmd...] — respects DRY_RUN
  if [[ "$DRY_RUN" -eq 1 ]]; then
    info "[dry-run] $*"
  else
    info "exec: $*"
    "$@"
  fi
}

cleanup() {
  local code=$?
  if [[ -n "$TEMP_LIST" && -f "$TEMP_LIST" ]]; then
    rm -f "$TEMP_LIST"
  fi
  if [[ "$code" -ne 0 ]]; then
    error "exiting with status $code"
  fi
  exit "$code"
}

# ── argument parsing ───────────────────────────────
parse_args() {
  local opt
  while getopts ":hvno:" opt; do
    case "$opt" in
      h) usage; exit 0 ;;
      v) VERBOSE=1 ;;
      n) DRY_RUN=1 ;;
      o) OUTPUT_DIR="$OPTARG" ;;
      \?) error "unknown option: -$OPTARG"; usage; exit 2 ;;
      :)  error "option -$OPTARG requires an argument"; usage; exit 2 ;;
    esac
  done
  shift $((OPTIND - 1))

  if [[ $# -lt 1 ]]; then
    error "missing SOURCE directory argument"
    usage
    exit 2
  fi
  if [[ $# -gt 1 ]]; then
    error "unexpected extra arguments: $*"
    usage
    exit 2
  fi
  SOURCE="$1"
}

# ── validation ─────────────────────────────────────
validate_inputs() {
  require_cmd tar
  require_cmd find
  require_cmd date

  if [[ ! -d "$SOURCE" ]]; then
    error "SOURCE is not a directory: $SOURCE"
    exit 2
  fi

  if [[ ! -d "$OUTPUT_DIR" ]]; then
    info "creating output directory: $OUTPUT_DIR"
    run mkdir -p "$OUTPUT_DIR"
  fi

  if [[ ! -w "$OUTPUT_DIR" ]]; then
    error "output directory is not writable: $OUTPUT_DIR"
    exit 2
  fi
}

# ── core work ──────────────────────────────────────
prune_old_backups() {
  [[ "$RETENTION_DAYS" -eq 0 ]] && return 0

  info "pruning backups older than $RETENTION_DAYS days in $OUTPUT_DIR"
  run find "$OUTPUT_DIR" -maxdepth 1 -name '*.tar.gz' -mtime "+$RETENTION_DAYS" -print -delete
}

run_backup() {
  local base dest ts
  base="$(basename "$SOURCE")"
  ts="$(date '+%Y%m%d-%H%M%S')"
  dest="$OUTPUT_DIR/${base}-${ts}.tar.gz"

  TEMP_LIST="$(mktemp)"
  trap cleanup EXIT

  info "backing up $SOURCE$dest"
  run tar -czf "$dest" -C "$(dirname "$SOURCE")" "$base"

  if [[ "$DRY_RUN" -eq 0 ]]; then
    local size
    size="$(du -h "$dest" | cut -f1)"
    info "backup complete: $dest ($size)"
  fi

  prune_old_backups
  info "done"
}

# ── entry point ────────────────────────────────────
main() {
  parse_args "$@"
  validate_inputs
  run_backup
}

main "$@"

Walk through what each layer does {Đi qua từng lớp làm gì}:

  • set -euo pipefail — any failure stops the script; unset variables error; piped failures propagate {mọi lỗi dừng script; biến chưa set báo lỗi; lỗi trong pipe lan truyền} (Part 9).
  • getopts + usage — predictable CLI; unknown flags exit 2 {CLI dự đoán được; flag lạ thoát 2}.
  • require_cmd — fail before work if tar/find missing {fail trước khi làm việc nếu thiếu tar/find}.
  • mktemp + trap cleanup EXIT — temp file removed on success, error, or Ctrl-C {file tạm xóa khi thành công, lỗi, hoặc Ctrl-C}.
  • run() + -n — dry-run mode for CI previews and operator confidence {chế độ dry-run cho preview CI và tự tin vận hành}.
  • Meaningful exit codes2 for misuse, 1 for runtime (implicit via set -e) {exit code có nghĩa — 2 dùng sai, 1 runtime (ngầm qua set -e)}.

Best practices checklist {Checklist best practice}

Before you merge or deploy a shell script, run through this list {Trước khi merge hoặc deploy shell script, chạy qua checklist này}:

Check {Kiểm tra}Why {Vì sao}
#!/usr/bin/env bash shebangPortable bash path {Đường dẫn bash portable}
set -euo pipefail at top (after any needed -e exceptions)Fail fast, no silent unset vars {Fail nhanh, không biến unset im lặng}
usage() + -h + exit 2 on bad argsOperators know how to run it {Vận hành biết cách chạy}
Logs to stderr, data to stdoutSafe piping {Pipe an toàn}
command -v for external depsClear error instead of “command not found” mid-run {Lỗi rõ thay vì “command not found” giữa chừng}
trap cleanup EXIT for temp files / locksNo leftover junk on failure {Không rác tạm khi lỗi}
Quote variables: "$var", "$@"Word-splitting and glob surprises (Part 2) {Word-splitting và glob bất ngờ (Phần 2)}
readonly for constantsAccidental overwrite caught early {Ghi đè nhầm bị bắt sớm}
Env vars documented + defaults via ${VAR:-default}Configurable without editing source {Cấu hình không cần sửa source}
Run ShellCheck (shellcheck script.sh)Catches common bugs before runtime {Bắt bug phổ biến trước runtime}
LF line endings, chmod +xNo \r surprises (Part 1) {Không bất ngờ \r (Phần 1)}

When NOT to use bash {Khi KHÔNG nên dùng bash}

Bash excels at gluing Unix tools and automating tasks that are mostly spawning processes and moving files {Bash giỏi ghép công cụ Unix và tự động hóa tác vụ chủ yếu spawn process và di chuyển file}. Reach for Python, Go, or another language when {Chuyển sang Python, Go, hoặc ngôn ngữ khác khi}:

  • Logic gets complex — nested conditionals, state machines, or business rules that need unit tests {Logic phức tạp — điều kiện lồng nhau, state machine, hoặc rule nghiệp vụ cần unit test}.
  • You need real data structures — dicts, JSON parsing, CSV with typed columns, ORM/database layers {Cần cấu trúc dữ liệu thật — dict, parse JSON, CSV có cột kiểu, lớp ORM/database}.
  • Cross-platform is required — Windows without WSL, or macOS + Linux with identical behavior beyond #!/usr/bin/env bash {Cần cross-platform — Windows không WSL, hoặc macOS + Linux hành vi giống hệt ngoài #!/usr/bin/env bash}.
  • Heavy string or JSON workjq helps, but maintaining 200 lines of jq + bash is often harder than 50 lines of Python {Xử lý string/JSON nặngjq giúp, nhưng bảo trì 200 dòng jq + bash thường khó hơn 50 dòng Python}.
  • Performance at scale — processing gigabytes in-process, tight loops, or concurrency beyond background jobs {Hiệu năng quy mô lớn — xử lý gigabyte trong process, vòng lặp chặt, hoặc concurrency vượt background job}.
  • Team maintainability — if only one person reads bash and everyone else reads TypeScript, a small Node/Python CLI may win {Bảo trì theo team — nếu chỉ một người đọc bash còn cả team đọc TypeScript, CLI Node/Python nhỏ có thể thắng}.

A common pattern: bash script as a thin wrapper that validates env, then exec python -m mytool "$@" {Pattern phổ biến: bash là lớp bọc mỏng validate env, rồi exec python -m mytool "$@"}.


Mistakes even experienced devs make {Lỗi cả dev có kinh nghiệm vẫn mắc}

  • ❌ Parsing "$@" with for arg in $* — breaks on spaces in filenames {Parse "$@" bằng for arg in $* — vỡ khi tên file có khoảng trắng}. Use "$@" or getopts {Dùng "$@" hoặc getopts}.
  • ❌ Forgetting shift $((OPTIND - 1)) after getopts — positional args still include flags {Quên shift $((OPTIND - 1)) sau getopts — arg vị trí vẫn còn flag}.
  • ❌ Logging to stdout in a script meant to be piped {Log ra stdout trong script để pipe}. Operators get corrupted data streams {Vận hành nhận luồng dữ liệu hỏng}.
  • trap set after the code that creates temp files — early exit skips cleanup {trap đặt sau code tạo file tạm — thoát sớm bỏ qua cleanup}. Set trap as soon as mktemp succeeds {Đặt trap ngay khi mktemp thành công}.
  • set -e inside a function without thinking — a failed command in a conditional can still surprise you; test with set -e explicitly in mind (Part 9) {set -e trong function không suy nghĩ — lệnh fail trong điều kiện vẫn có thể gây bất ngờ; test với set -e trong đầu (Phần 9)}.
  • ❌ Growing a 400-line bash script with JSON APIs instead of admitting it is now an application {Để script bash 400 dòng gọi JSON API thay vì thừa nhận đó giờ là ứng dụng}.

Exercises {Bài tập}

Try each before opening the solution {Thử từng bài trước khi mở lời giải}.

  1. Extend backup-dir.sh with a -k N flag that overrides RETENTION_DAYS for a single run (without exporting the env var) {Mở rộng backup-dir.sh với flag -k N ghi đè RETENTION_DAYS cho một lần chạy (không export biến env)}.
  2. Write a minimal deploy.sh that accepts -h, -v, and -t TARGET (required), checks git and ssh exist, and in dry-run mode (-n) only prints rsync -av ./dist/ "$TARGET" {Viết deploy.sh tối thiểu nhận -h, -v, và -t TARGET (bắt buộc), kiểm tra gitssh tồn tại, và ở dry-run (-n) chỉ in rsync -av ./dist/ "$TARGET"}.
  3. Run shellcheck on your script from exercise 2 and fix every warning it reports {Chạy shellcheck trên script bài 2 và sửa mọi cảnh báo}.
Solution {Lời giải}
# Exercise 1 — add to parse_args() in backup-dir.sh:

# In the option string, add k::
while getopts ":hvnk:o:" opt; do
  case "$opt" in
    # ... existing cases ...
    k) RETENTION_DAYS="$OPTARG" ;;
  esac
done

# And in usage(), document:
#   -k N    keep backups for N days (overrides RETENTION_DAYS for this run)
#!/usr/bin/env bash
# deploy.sh — exercise 2 + 3 (ShellCheck-clean)
set -euo pipefail

readonly SCRIPT_NAME="$(basename "$0")"
VERBOSE=0
DRY_RUN=0
TARGET=""

usage() {
  cat >&2 <<EOF
Usage: $SCRIPT_NAME [-hvn] -t TARGET

  -h    help
  -v    verbose
  -n    dry-run
  -t    rsync target (required), e.g. user@host:/var/www/app
EOF
}

log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }

require_cmd() {
  command -v "$1" >/dev/null 2>&1 || { log "missing: $1"; exit 2; }
}

main() {
  local opt
  while getopts ":hvnt:" opt; do
    case "$opt" in
      h) usage; exit 0 ;;
      v) VERBOSE=1 ;;
      n) DRY_RUN=1 ;;
      t) TARGET="$OPTARG" ;;
      \?) log "unknown option: -$OPTARG"; usage; exit 2 ;;
      :)  log "-$OPTARG needs an argument"; usage; exit 2 ;;
    esac
  done

  if [[ -z "$TARGET" ]]; then
    log "missing -t TARGET"
    usage
    exit 2
  fi

  require_cmd git
  require_cmd ssh

  [[ "$VERBOSE" -eq 1 ]] && log "target=$TARGET dry_run=$DRY_RUN"

  if [[ "$DRY_RUN" -eq 1 ]]; then
    log "[dry-run] rsync -av ./dist/ $TARGET"
  else
    rsync -av ./dist/ "$TARGET"
  fi
}

main "$@"
shellcheck deploy.sh   # exercise 3 — should report no issues

Series recap {Tóm tắt series}

You made it through all ten parts {Bạn đã hoàn thành cả mười phần}. Here is the full arc {Đây là toàn bộ lộ trình}:


Takeaway {Điều cốt lõi}

A production bash script is not a bag of commands — it is a small program with a strict header, parsed arguments, validated inputs, structured functions, stderr logging, env-based config, trapped cleanup, and meaningful exit codes {Script bash production không phải túi lệnh — mà là chương trình nhỏ có header strict, tham số đã parse, input đã validate, function có cấu trúc, log stderr, config từ env, cleanup có trap, và exit code có nghĩa}. Use bash where it shines (glue, deploy hooks, backups); reach for a richer language when the script starts looking like an app {Dùng bash nơi nó mạnh (glue, deploy hook, backup); chuyển sang ngôn ngữ mạnh hơn khi script bắt đầu giống ứng dụng}. You now have the full toolkit — go automate something real {Giờ bạn có đủ công cụ — hãy tự động hóa thứ gì đó thật}.