DevOps CI/CD Part 2: Linux and Shell Basics Every DevOps Engineer Needs

DevOps CI/CD Part 2 banner: Linux and shell basics for DevOps – files, permissions, pipes, processes, environment variables and scripts
PART 2 DevOps CI/CD Series · DevDojo Linux & Shell Basics for DevOps Files, permissions, pipes and scripts that every pipeline runs on $ chmod +x deploy.sh $ ./deploy.sh && echo “shipped” shipped ▌ $_bash Files Permissions Pipes Processes Env vars Scripts devdojo.co.in
Series: DevOps CI/CD Part 2 of 30

What you should know first: read Part 1: What Is DevOps and How Does CI/CD Work? so you know what a pipeline and a runner are. You need a Linux or macOS terminal, or WSL on Windows. No earlier Linux experience is needed.

In Part 1 you built a small pipeline and saw that every run: line was a shell command running on a Linux machine. That is why Linux for DevOps is the first real skill in this series. CI runners, Docker containers, cloud servers and Kubernetes nodes almost all run Linux, and you talk to them through a shell. In this part you will learn the Linux commands and shell basics that DevOps engineers use every day: moving around the file system, file permissions, pipes and logs, environment variables, exit codes and processes. Then you will write two real scripts, a log report and a health check, and run them inside a GitHub Actions pipeline.

Quick Summary
  • A shell (usually bash) is the program that reads your commands. Pipelines, Dockerfiles and servers all use it.
  • Learn about 25 core commands: ls, cd, mkdir, cp, mv, cat, grep, awk, chmod, ps, curl and friends.
  • Permissions (rwx, numbers like 755 and 600) decide who can read, change or run a file. “Permission denied” is the most common pipeline error.
  • Pipes (|) and redirection (>, >>, 2>) join small tools into powerful one-liners.
  • Every command returns an exit code: 0 means success, anything else means failure. CI tools use it to decide if a step passed.
  • Start every script with set -euo pipefail, quote your variables and check scripts with ShellCheck.
Full DevOps CI/CD roadmap (30 parts)

Beginner

  1. What Is DevOps and How Does CI/CD Work?
  2. Linux and Shell Basics Every DevOps Engineer Needs You are here
  3. Git for Teams: Branches, Pull Requests and Trunk-Based Development Coming soon
  4. Automated Testing for Pipelines: Unit, Integration and End-to-End Coming soon
  5. Your First CI Pipeline with GitHub Actions Coming soon
  6. Docker Basics: Images, Containers and Dockerfiles Coming soon
  7. Writing Small, Secure, Production-Ready Dockerfiles Coming soon
  8. Container Registries and Image Versioning Coming soon
  9. Build Artifacts, Versioning and Semantic Releases Coming soon
  10. Continuous Delivery vs Continuous Deployment: Your First Release Pipeline Coming soon

Intermediate

  1. Environments, Configuration and Secrets Management Coming soon
  2. GitHub Actions Deep Dive: Matrix Builds, Caching and Reusable Workflows Coming soon
  3. GitLab CI/CD and Jenkins: How Other CI Tools Work Coming soon
  4. Infrastructure as Code with Terraform Coming soon
  5. Configuration Management with Ansible Coming soon
  6. Kubernetes Essentials for CI/CD Coming soon
  7. Packaging Apps for Kubernetes with Helm and Kustomize Coming soon
  8. GitOps with Argo CD Coming soon
  9. Deployment Strategies: Rolling, Blue-Green and Canary Coming soon
  10. Feature Flags and Progressive Delivery Coming soon

Advanced

  1. DevSecOps: Code, Dependency and Container Scanning in CI Coming soon
  2. Software Supply Chain Security: SBOMs, Signing and SLSA Coming soon
  3. Observability: Metrics, Logs and Traces with Prometheus, Grafana and OpenTelemetry Coming soon
  4. Alerts, SLOs and Incident Response Coming soon
  5. Fast CI for Monorepos: Affected Builds and Remote Caching Coming soon
  6. Self-Hosted Runners and Scaling CI Cost-Effectively Coming soon
  7. Platform Engineering and Internal Developer Platforms Coming soon
  8. AI in DevOps: AI Code Review and Agentic Pipelines Coming soon
  9. Measuring DevOps with DORA Metrics Coming soon
  10. Final Project: A Complete Pipeline from Commit to Production Coming soon

Why Linux for DevOps matters

When you write runs-on: ubuntu-latest in a GitHub Actions file, GitHub gives you a fresh Ubuntu Linux machine. When you build a Docker image, it is based on a small Linux system such as Debian or Alpine. When you deploy to AWS, Azure, Google Cloud or a Kubernetes cluster, your app almost always runs on Linux. So if you can work comfortably in a Linux shell, you can understand and fix every step of a pipeline.

Where Linux shows up in a CI/CD pipeline Your laptopbash / zsh terminal(WSL on Windows) CI runnerubuntu-latest VMruns your run: lines ContainerLinux image (Alpine,Debian) + your app Server / clustercloud VM or Kubernetesnode running Linux push build deploy The same shell skills work at every step: files, permissions, pipes, variables, processes
Figure 1: From your laptop to production, almost every step of a pipeline runs on Linux.
Debug failing pipelinesMost CI errors are shell errors: a missing file, a wrong path, “permission denied” or “command not found”.
Write DockerfilesEvery RUN line in a Dockerfile is a shell command. You will use this from Part 6.
Look after serversRead logs, restart services, check disk space and find what is using the CPU.
Automate boring workA 20-line script can replace a 20-step manual checklist, and it never forgets a step.

Terminal, shell and the Linux file system

Three words are often mixed up, so let us make them clear:

  • Terminal – the window you type in (Windows Terminal, macOS Terminal, the VS Code terminal).
  • Shell – the program inside the terminal that reads your command, runs it and prints the result. bash is the most common shell on Linux servers and CI runners; macOS uses zsh by default, which works almost the same for everything in this post.
  • Prompt – the $ sign that says “I am ready”. In this series, lines that start with $ are commands you type; do not type the $ itself.
On Windows? Install WSL (Windows Subsystem for Linux) with wsl --install in an administrator PowerShell, restart, and you get a real Ubuntu terminal. Everything in this series works there.

Linux has one big folder tree that starts at / (called “root”). There are no drive letters like C:. These are the folders you will meet most often in DevOps work:

FolderWhat lives thereDevOps example
/The top of the treeEvery absolute path starts here
/home/<user> (or ~)Your personal filesOn a GitHub runner your code is checked out under /home/runner/work/
/etcSystem and app configuration/etc/nginx/nginx.conf, /etc/hosts
/var/logLog files/var/log/nginx/access.log
/usr/bin, /usr/local/binInstalled programsWhere git, node and docker live
/tmpTemporary files, often cleared on rebootScratch files in a pipeline
/opt, /srvExtra software and served dataA common place to deploy an app

A path that starts with / is absolute (it works from anywhere). A path without it, like logs/app.log, is relative to the folder you are in now. Many “file not found” errors in CI happen because a script was run from a different folder than you expected.

Essential Linux commands for DevOps: files and folders

Let us create a small project layout, write a config file and move things around. Click ▶ Run to see the real output of each command.

files.sh — devops-part2 — Visual Studio Code
devops-part2 › files.sh
# Where am I? Then create a small project layout
pwd
mkdir -p myapp/config myapp/logs
cd myapp
touch app.log
echo "PORT=8080" > config/app.env
echo "MODE=production" >> config/app.env
cat config/app.env
cp config/app.env config/app.env.bak
mv app.log logs/
ls -R
TERMINALOUTPUTPROBLEMSReal output (bash 5.2, Ubuntu)
$ pwd
/home/dev
$ mkdir -p myapp/config myapp/logs
$ cd myapp
$ touch app.log
$ echo "PORT=8080" > config/app.env
$ echo "MODE=production" >> config/app.env
$ cat config/app.env
PORT=8080
MODE=production
$ cp config/app.env config/app.env.bak
$ mv app.log logs/
$ ls -R
.:
config
logs

./config:
app.env
app.env.bak

./logs:
app.log
⎇ mainLn 11, Col 1Spaces: 2UTF-8LFShell Script

Click ▶ Run to open the terminal panel. Every line starting with $ is a command; the lines below it are what the shell printed.

Notice the difference between > and >>: the first one creates or overwrites a file, the second one adds a line at the end. Overwriting a config file by mistake with > is a classic beginner bug, so slow down when you type it.

Here is a cheat sheet of the commands you will use most. Click a tab to switch the group:

CommandWhat it does
pwdPrint the folder you are in
ls -laList files, including hidden ones (names starting with a dot), with details
cd /var/log, cd .., cd ~Change folder: absolute path, one level up, your home folder
mkdir -p a/b/cCreate folders, including missing parents, without errors if they exist
cp -r src dest / mv old newCopy (recursively for folders) / move or rename
rm file / rm -r folderDelete permanently – there is no recycle bin
find . -name "*.log"Find files by name below the current folder
CommandWhat it does
chmod +x deploy.shMake a script executable
chmod 644 file / chmod 600 .envOwner can write, everyone can read / only the owner can read and write
chown app:app /srv/appChange the owner and group of a file or folder
whoami, idShow your user name, user ID and groups
sudo commandRun one command as the administrator (root)
stat fileShow permissions, owner, size and times of a file
CommandWhat it does
cat, lessPrint a file / scroll through a long file (press q to quit)
head -n 20, tail -n 20, tail -fFirst lines, last lines, follow a growing log live
grep -i "error" app.logShow lines that match a word (-i ignores case, -c counts, -r searches folders)
awk '{print $1}'Print a column (fields are split by spaces)
sort | uniq -cGroup equal lines and count them
wc -lCount lines
sed 's/old/new/g'Find and replace text in a stream or file
CommandWhat it does
ps aux, top / htopList running processes / live CPU and memory view
kill PID, kill -9 PIDAsk a process to stop / force it to stop (last resort)
systemctl status|restart nameCheck or restart a background service
journalctl -u name -fFollow the logs of a service
curl -I https://siteCall a URL and show only the response headers
ss -tlnpShow which ports are open and which program listens
df -h, free -hFree disk space / free memory, in human-readable units
Careful with rm. Linux has no recycle bin. rm -rf deletes everything inside a folder without asking. In scripts, never run rm -rf "$DIR/" without checking that $DIR is not empty first – set -u (see below) helps protect you.

File permissions: read, write and execute

Every file on Linux has an owner, a group and a set of permissions for three kinds of people: the owner, members of the group, and everyone else (“others”). Each of them can have r (read), w (write) and x (execute) permission. You see them as a 10-character string like -rwxr-xr-x, or as a 3-digit number like 755.

Reading -rwxr-xr-x (755) – rwx r–x r–x type– file, d folder owner (u)4 + 2 + 1 = 7 group (g)4 + 0 + 1 = 5 others (o)4 + 0 + 1 = 5 r = 4 read w = 2 write x = 1 execute (run / enter folder)
Figure 2: Each group of three letters is one number. Add r=4, w=2 and x=1 to get 7, 5 and 5, so chmod 755 gives exactly this permission.

Now let us see the most famous DevOps error and how to fix it:

permissions.sh — devops-part2 — Visual Studio Code
devops-part2 › permissions.sh
# Create a tiny deploy script and try to run it
printf '#!/usr/bin/env bash\necho "Deploying..."\n' > deploy.sh
stat -c '%A %a %n' deploy.sh
./deploy.sh
# Give the owner execute permission, then run again
chmod +x deploy.sh
stat -c '%A %a %n' deploy.sh
./deploy.sh
# Secrets: only the owner may read or write
echo "DB_PASSWORD=change-me" > .env
chmod 600 .env
stat -c '%A %a %n' .env
TERMINALOUTPUTPROBLEMSReal output (bash 5.2, GNU coreutils)
$ printf '#!/usr/bin/env bash\necho "Deploying..."\n' > deploy.sh
$ stat -c '%A %a %n' deploy.sh
-rw-r--r-- 644 deploy.sh
$ ./deploy.sh
bash: ./deploy.sh: Permission denied
$ chmod +x deploy.sh
$ stat -c '%A %a %n' deploy.sh
-rwxr-xr-x 755 deploy.sh
$ ./deploy.sh
Deploying...
$ echo "DB_PASSWORD=change-me" > .env
$ chmod 600 .env
$ stat -c '%A %a %n' .env
-rw------- 600 .env
⎇ mainLn 12, Col 1Spaces: 2UTF-8LFShell Script

Click ▶ Run. The first ./deploy.sh fails with “Permission denied” because new files are not executable; after chmod +x it works.

Three permission numbers cover almost every case:

NumberStringUse it for
755rwxr-xr-xScripts and folders: everyone can read and run, only the owner can change
644rw-r--r--Normal files such as code and config
600rw-------Secrets: .env files, SSH private keys (SSH refuses keys that others can read)
Pipeline tip: Git stores the execute bit. Run chmod +x script.sh and commit, or use git update-index --chmod=+x script.sh on Windows. Then your CI step can run ./script.sh without a separate chmod step.

The special user root can do anything. Instead of logging in as root, you run single commands with sudo. On GitHub-hosted runners the runner user can use sudo without a password, which is handy for sudo apt-get install. Inside Docker containers you should do the opposite and run your app as a normal user – we will do that in Part 7.

Pipes, redirection and reading logs

Linux tools are small and do one thing well. The pipe | connects them: the output of the left command becomes the input of the right command. Together with redirection you can turn a messy log file into a clear report in one line.

SymbolMeaningExample
|Send output to the next commandcat app.log | grep ERROR
>Write output to a file (overwrite)npm test > result.txt
>>Append output to a fileecho "done" >> deploy.log
2>Write errors (stderr) to a filebuild.sh 2> errors.txt
2>&1Send errors to the same place as normal outputbuild.sh > all.log 2>&1

We will use this small web server log for the rest of the post (save it as logs/access.log). The 9th column is the HTTP status code:

access.log — devops-part2 — Visual Studio Code
devops-part2 › logs › access.log
# logs/access.log - sample web server log used in this part
10.0.0.5 - - [28/Oct/2026:10:01:12 +0530] "GET / HTTP/1.1" 200 512
10.0.0.7 - - [28/Oct/2026:10:01:15 +0530] "GET /api/products HTTP/1.1" 200 2048
10.0.0.5 - - [28/Oct/2026:10:01:19 +0530] "POST /api/cart HTTP/1.1" 201 128
10.0.0.9 - - [28/Oct/2026:10:02:03 +0530] "GET /api/products HTTP/1.1" 500 64
10.0.0.5 - - [28/Oct/2026:10:02:10 +0530] "GET /api/orders HTTP/1.1" 200 1024
10.0.0.7 - - [28/Oct/2026:10:02:44 +0530] "GET /login HTTP/1.1" 200 640
10.0.0.12 - - [28/Oct/2026:10:03:01 +0530] "GET /api/products HTTP/1.1" 502 64
10.0.0.5 - - [28/Oct/2026:10:03:20 +0530] "GET /api/products HTTP/1.1" 200 2048
10.0.0.7 - - [28/Oct/2026:10:03:41 +0530] "GET /missing HTTP/1.1" 404 32
10.0.0.9 - - [28/Oct/2026:10:04:05 +0530] "GET / HTTP/1.1" 200 512
⎇ mainLn 11, Col 1Spaces: 2UTF-8LFShell Script
A pipe | sends one command’s output into the next access.log10 lines|awk ‘{print $9}’status codes|sortgroup equal|uniq -ccount|sort -rnbiggest first Result: “6 200, 1 502, 1 500 …” – a status-code report in one line of shell
Figure 3: Small tools joined with pipes. Each tool does one job; together they turn a raw log into a report.
logs.sh — devops-part2 — Visual Studio Code
devops-part2 › logs.sh
# Show only server errors (status 500-599)
grep -E '" 5[0-9]{2} ' logs/access.log
# How many requests hit the products API?
grep -c "/api/products" logs/access.log
# Count requests per status code, most common first
awk '{print $9}' logs/access.log | sort | uniq -c | sort -rn
# The last 2 lines (use tail -f to follow a live log)
tail -n 2 logs/access.log
# Save 404s to a file, then read it
grep " 404 " logs/access.log > not-found.txt
cat not-found.txt
TERMINALOUTPUTPROBLEMSReal output (GNU grep, awk, sort)
$ grep -E '" 5[0-9]{2} ' logs/access.log
10.0.0.9 - - [28/Oct/2026:10:02:03 +0530] "GET /api/products HTTP/1.1" 500 64
10.0.0.12 - - [28/Oct/2026:10:03:01 +0530] "GET /api/products HTTP/1.1" 502 64
$ grep -c "/api/products" logs/access.log
4
$ awk '{print $9}' logs/access.log | sort | uniq -c | sort -rn
      6 200
      1 502
      1 500
      1 404
      1 201
$ tail -n 2 logs/access.log
10.0.0.7 - - [28/Oct/2026:10:03:41 +0530] "GET /missing HTTP/1.1" 404 32
10.0.0.9 - - [28/Oct/2026:10:04:05 +0530] "GET / HTTP/1.1" 200 512
$ grep " 404 " logs/access.log > not-found.txt
$ cat not-found.txt
10.0.0.7 - - [28/Oct/2026:10:03:41 +0530] "GET /missing HTTP/1.1" 404 32
⎇ mainLn 11, Col 1Spaces: 2UTF-8LFShell Script

Click ▶ Run to see what each command finds in the sample log.

The awk '{print $9}' | sort | uniq -c | sort -rn pattern is worth remembering: change $9 to $1 and you get the top IP addresses; change it to $7 and you get the most requested URLs. On a real server you would point it at /var/log/nginx/access.log, and use tail -f to watch new requests arrive live while you test a deployment.

Environment variables and exit codes

Environment variables are named values that a shell passes to every program it starts. Pipelines use them everywhere: NODE_ENV=production, DATABASE_URL, API keys from secrets, and built-in values such as GITHUB_SHA (the commit being built) and CI=true. You read a variable with $NAME or ${NAME}, and you can give a default with ${NAME:-default}.

The second idea is the exit code. Every command ends with a number: 0 means success and anything from 1 to 255 means failure. $? holds the exit code of the last command. This number is how CI tools decide if a step passed – if any command in a step exits with a non-zero code, the step turns red and the pipeline stops.

env.sh — devops-part2 — Visual Studio Code
devops-part2 › env.sh
# Set a variable for this shell and every program it starts
export APP_ENV=staging
echo "Deploying to $APP_ENV"
# Use a default when a variable is not set
echo "Port is ${PORT:-3000}"
# Every command returns an exit code: 0 = success
ls /does-not-exist; echo "exit code: $?"
# && runs the next command only on success, || only on failure
mkdir -p build && echo "build folder ready"
false || echo "previous command failed, running fallback"
TERMINALOUTPUTPROBLEMSReal output (bash 5.2)
$ export APP_ENV=staging
$ echo "Deploying to $APP_ENV"
Deploying to staging
$ echo "Port is ${PORT:-3000}"
Port is 3000
$ ls /does-not-exist; echo "exit code: $?"
ls: cannot access '/does-not-exist': No such file or directory
exit code: 2
$ mkdir -p build && echo "build folder ready"
build folder ready
$ false || echo "previous command failed, running fallback"
previous command failed, running fallback
⎇ mainLn 10, Col 1Spaces: 2UTF-8LFShell Script

Click ▶ Run. Notice exit code: 2 after the failing ls, and how && and || choose what runs next.

Two useful rules: variables you set without export are only visible in the current shell, not in programs it starts. And each run: step in GitHub Actions starts a new shell, so a plain export does not carry over to the next step – write echo "NAME=value" >> "$GITHUB_ENV" instead. We will go deeper into configuration and secrets in Part 11.

Processes and services

A process is a running program. Each one has a number called a PID. You need a few commands to see what is running, run things in the background and stop them:

processes.sh — devops-part2 — Visual Studio Code
devops-part2 › processes.sh
# List processes (PID = process ID, COMMAND = program name)
ps -eo pid,comm | head -3
# Start a long command in the background with &
sleep 300 &
# See background jobs of this shell, then stop job 1
jobs
kill %1
TERMINALOUTPUTPROBLEMSExample output from a cloud sandbox – PIDs and names differ on your machine
$ ps -eo pid,comm | head -3
  PID COMMAND
    1 process_api
    2 kthreadd
$ sleep 300 &
[1] 381
$ jobs
[1]+  Running                 sleep 300 &
$ kill %1
[1]+  Terminated              sleep 300
⎇ mainLn 7, Col 1Spaces: 2UTF-8LFShell Script

Click ▶ Run to see the example output. Use kill PID first and keep kill -9 as a last resort, because it gives the program no chance to clean up.

On servers, long-running programs such as web servers and databases run as services, managed by systemd. You will not need these inside a CI runner, but you will need them the first time you log in to a server to check why a site is down:

server — bash
# On a Linux server that uses systemd (Ubuntu, Debian, RHEL...)
$ sudo systemctl status nginx      # is the web server running?
$ sudo systemctl restart nginx     # restart after a config change
$ sudo journalctl -u nginx -n 50   # last 50 log lines of the service
$ ss -tlnp                          # which programs listen on which ports
$ df -h && free -h                  # disk space and memory

Your first DevOps shell scripts

A shell script is a text file full of commands that run from top to bottom. Let us write two scripts that real teams use in pipelines:

  1. log-report.sh – reads an access log, prints a summary and fails (exit code 1) if too many requests were server errors. You could run it after a canary deployment to decide whether to continue.
  2. healthcheck.sh – waits until a URL answers with HTTP 200, trying a few times. You run it right after a deploy, or before end-to-end tests.

The third tab shows a GitHub Actions workflow that runs both scripts on every push, just like the pipeline you built in Part 1.

3 files — devops-part2 — Visual Studio Code
devops-part2 › log-report.sh
#!/usr/bin/env bash
# log-report.sh - summarise a web server access log and fail on too many errors.
# Usage: ./log-report.sh logs/access.log
set -euo pipefail

LOG_FILE="${1:?Usage: $0 <access-log-file>}"
MAX_ERROR_PCT="${MAX_ERROR_PCT:-25}"   # override with an environment variable

if [[ ! -f "$LOG_FILE" ]]; then
  echo "ERROR: file not found: $LOG_FILE" >&2
  exit 2
fi

total=$(wc -l < "$LOG_FILE")
errors=$(awk '$9 >= 500' "$LOG_FILE" | wc -l)
error_pct=$(( errors * 100 / total ))

echo "Requests : $total"
echo "5xx errors: $errors ($error_pct%)"
echo "Top IPs:"
awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -3

if (( error_pct > MAX_ERROR_PCT )); then
  echo "FAIL: error rate $error_pct% is above $MAX_ERROR_PCT%" >&2
  exit 1
fi
echo "OK: error rate is within the limit"
TERMINALOUTPUTPROBLEMSReal output (bash 5.2)
$ ./log-report.sh logs/access.log
Requests : 10
5xx errors: 2 (20%)
Top IPs:
      4 10.0.0.5
      3 10.0.0.7
      2 10.0.0.9
OK: error rate is within the limit
$ MAX_ERROR_PCT=10 ./log-report.sh logs/access.log; echo "exit code: $?"
Requests : 10
5xx errors: 2 (20%)
Top IPs:
      4 10.0.0.5
      3 10.0.0.7
      2 10.0.0.9
FAIL: error rate 20% is above 10%
exit code: 1
⎇ mainLn 27, Col 1Spaces: 2UTF-8LFShell Script
devops-part2 › healthcheck.sh
#!/usr/bin/env bash
# healthcheck.sh - wait until a URL answers with HTTP 200, or give up.
# Usage: ./healthcheck.sh http://localhost:8080/ [retries] [delay-seconds]
set -euo pipefail

url="${1:?Usage: $0 <url> [retries] [delay]}"
retries="${2:-5}"
delay="${3:-2}"

check() {
  # -s silent, -o discard body, -w print only the status code
  curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$1" || true
}

for (( i = 1; i <= retries; i++ )); do
  code=$(check "$url")
  echo "[$i/$retries] $url -> HTTP $code"
  if [[ "$code" == "200" ]]; then
    echo "Service is healthy"
    exit 0
  fi
  sleep "$delay"
done

echo "Service did not become healthy after $retries tries" >&2
exit 1
TERMINALOUTPUTPROBLEMSReal output (bash 5.2, curl)
# Terminal 1: python3 -m http.server 8080   (started a few seconds late)
$ ./healthcheck.sh http://localhost:8080/ 5 2
[1/5] http://localhost:8080/ -> HTTP 000
[2/5] http://localhost:8080/ -> HTTP 000
[3/5] http://localhost:8080/ -> HTTP 200
Service is healthy
$ ./healthcheck.sh http://localhost:9999/ 3 1; echo "exit code: $?"
[1/3] http://localhost:9999/ -> HTTP 000
[2/3] http://localhost:9999/ -> HTTP 000
[3/3] http://localhost:9999/ -> HTTP 000
Service did not become healthy after 3 tries
exit code: 1
⎇ mainLn 26, Col 1Spaces: 2UTF-8LFShell Script
devops-part2 › .github › workflows › scripts.yml
# .github/workflows/scripts.yml - run our shell scripts on every push
name: Script checks
on:
  push:
    branches: [main]
  pull_request:

jobs:
  checks:
    runs-on: ubuntu-latest        # a fresh Linux machine for every run
    steps:
      - uses: actions/checkout@v6

      - name: Lint the shell scripts
        run: shellcheck *.sh      # ShellCheck is preinstalled on Ubuntu runners

      - name: Fail if the error rate is too high
        env:
          MAX_ERROR_PCT: 25
        run: ./log-report.sh logs/access.log

      - name: Start a test web server in the background
        run: python3 -m http.server 8080 &

      - name: Wait until it answers
        run: ./healthcheck.sh http://localhost:8080/ 10 2
⎇ mainLn 26, Col 1Spaces: 2UTF-8LFYAML

Click the tabs to switch files, then ▶ Run on each script to see real output, including the failing cases with exit code 1.

Let us walk through the important lines of log-report.sh:

  • #!/usr/bin/env bash – the shebang. It tells Linux to run the file with bash, wherever bash is installed.
  • "${1:?Usage: ...}" – $1 is the first argument. :? stops the script with a helpful message if it is missing.
  • "${MAX_ERROR_PCT:-25}" – read a setting from an environment variable, with a default. The pipeline sets it in the env: block.
  • [[ ! -f "$LOG_FILE" ]] – a test: “is this not a regular file?”. >&2 sends the error message to stderr.
  • $(...) – command substitution: run a command and store its output in a variable.
  • $(( ... )) and (( ... )) – whole-number maths and number comparisons.
  • exit 1 – a non-zero exit code makes the CI step fail. This is how a script “blocks” a bad release.

Create the files, make them executable and commit them:

devops-part2 — bash
# Make the scripts executable once (Git remembers the x bit)
$ chmod +x log-report.sh healthcheck.sh
$ git add . && git commit -m "Add log report and health check scripts"
# Check them for common mistakes before pushing
$ shellcheck *.sh

Writing safe scripts: set -euo pipefail, quoting and ShellCheck

By default bash is very forgiving: if a command fails, it just continues with the next line. That is dangerous in a deploy script. Put this line at the top of every script:

OptionWhat it doesWhy it matters
set -eExit as soon as a command failsA failed build does not go on to deploy
set -uTreat unset variables as an errorA typo like $DIRR stops the script instead of becoming an empty string
set -o pipefailA pipeline fails if any part failsWithout it, broken-cmd | sort looks successful because sort worked

Always quote variables: write "$FILE", not $FILE. Without quotes, a file name with a space (my report.txt) is split into two words and your command breaks in strange ways.

Use ShellCheck: ShellCheck is a free linter that finds bugs in shell scripts, such as missing quotes or wrong tests. Both scripts in this post pass shellcheck with no warnings, and our workflow runs it on every push. The GNU Bash Reference Manual is the official place to look up any bash feature.

When to stop using bash: shell scripts are perfect for gluing commands together (up to about 100 lines). When you need complex data structures, JSON handling or lots of logic, switch to Python or Node.js. Many teams keep small bash scripts for CI steps and use a real language for anything bigger.

Common errors and how to fix them

Error messageWhy it happensFix
Permission deniedThe script is not executable, or you cannot read/write that pathchmod +x script.sh and commit it; check owners with ls -l; use sudo only for system paths
command not foundThe tool is not installed, or not in $PATH, or you forgot ./Install it (sudo apt-get install -y jq); run local scripts as ./script.sh; check with which tool
No such file or directoryWrong relative path; the script ran from another folderPrint pwd; use paths relative to the repo root; in scripts use cd "$(dirname "$0")"
/usr/bin/env: 'bash\r': No such file or directoryThe file has Windows line endings (CRLF)Run sed -i 's/\r$//' script.sh and add *.sh text eol=lf to .gitattributes
unbound variableset -u caught a variable that was never setSet it, or give a default: ${NAME:-default}
CI step “passes” although a command failedNo set -e, or the failure was hidden inside a pipeAdd set -euo pipefail at the top of the script
No space left on deviceThe disk is full (often old logs or Docker images)Check with df -h and du -sh /var/log/*; clean with docker system prune

Best practices for Linux and shell in DevOps

  • Keep scripts in the repository next to the code, so the pipeline and your laptop run exactly the same commands.
  • Start every script with #!/usr/bin/env bash and set -euo pipefail.
  • Quote every variable and run ShellCheck in CI.
  • Make scripts safe to run twice (idempotent): use mkdir -p, check before you delete, and do not append the same line twice.
  • Print clear messages and send errors to stderr (>&2), so CI logs are easy to read.
  • Never hard-code secrets in scripts. Read them from environment variables that come from your CI secret store.
  • Give secret files 600 permissions and never commit .env files.
  • Use long option names in shared scripts (--silent instead of -s) when it helps teammates read them.

Practice task

Build a tiny “release gate” script. Use the sample log from this post.

  1. Copy log-report.sh and add a second check: fail if there are more than 2 requests with status 404. Use an environment variable MAX_404 with a default of 2.
  2. Print the top 3 most requested URLs (hint: the 7th column, $7).
  3. Add one more line to logs/access.log with a 404, run the script again and check that the exit code (echo $?) changes.
  4. Run shellcheck on your script and fix every warning.
  5. Bonus: push it to GitHub with the scripts.yml workflow and watch the pipeline fail and then pass as you change MAX_404.

Want more pipeline practice right now? Our GitHub Actions CI/CD for Node.js guide shows a full Node.js workflow, and the Docker Compose tutorial uses many of the commands from this post.

Frequently asked questions

How much Linux do I need to know for DevOps?

For a beginner DevOps role you need the basics in this post: navigating files, permissions, pipes and text tools, environment variables, processes, services and simple scripts. Deeper topics such as networking, storage and kernel tuning come later with experience.

Should I learn bash or zsh?

Learn bash. It is the default shell on most Linux servers and CI runners, and almost everything you learn works the same in zsh, which macOS uses by default. Write your scripts for bash with #!/usr/bin/env bash.

Which Linux distribution should a DevOps beginner use?

Ubuntu is the easiest start: it is the default on GitHub Actions runners, very common in the cloud and has lots of tutorials. Later you will also meet Debian and Alpine in Docker images, and Red Hat-style systems (RHEL, Rocky Linux, Amazon Linux) in companies.

What does exit code 127 or 126 mean?

127 means “command not found” (the program is not installed or not in your PATH). 126 means the file was found but could not be executed, usually because it has no execute permission. 1 is a general error and 130 means you pressed Ctrl+C.

Can I practise Linux on Windows?

Yes. Install WSL with wsl --install to get a full Ubuntu terminal inside Windows. You can also use Git Bash for simple commands, or a free cloud shell from AWS, Google Cloud or Azure.

Conclusion

You now have the Linux for DevOps basics that every later part of this series builds on. You can move around the file system, read and change permissions, join tools with pipes to analyse logs, use environment variables and exit codes, manage processes and services, and write safe shell scripts that make a pipeline pass or fail for the right reasons. The best way to make these skills stick is to use the terminal every day – try doing your next small task without the mouse.

Later in the series these skills will come back again and again: in Dockerfiles, in Kubernetes debugging, in Terraform and Ansible, and in every pipeline step you write.

Next part – DevOps CI/CD Part 3: Git for Teams: Branches, Pull Requests and Trunk-Based Development. Every pipeline starts with a Git event. We will learn how teams branch, review and merge code safely, and which Git workflow works best with CI/CD.

New posts every day on DevDojo

Follow the full DevOps CI/CD series – 30 parts from “what is DevOps” to a complete pipeline from commit to production. Bookmark devdojo.co.in and tell us in the comments which Linux command you use the most.

Share