Instead of only reading commands and syntax, I used virtual machines, real scripts, and GitHub Copilot to understand how Bash is actually used in a DevOps-style workflow. What made this learning experience useful was not just writing scripts, but improving them, testing them, and understanding why certain Bash practices matter.
In this blog, I want to walk through what I learned step by step, from basic scripts to better practices like using functions, handling variables safely, validating files, using arrays, and even deploying scripts to multiple remote machines.
Why This Learning Style Helped Me
Bash can feel simple at first, but once you start automating real setup tasks, you quickly realize that structure and safety matter a lot. A script that works once is not enough. It should also be readable, reusable, and safe to run on real machines.
Step 1: Start with a Basic Script
The first scripts were simple system information scripts. They included commands to print uptime, memory usage, and disk usage. This was useful because it showed how Bash scripts are just a sequence of shell commands written in a reusable format.
#!/bin/bash
echo "System Information"
uptime
free -m
df -h
Even from a simple script like this, GitHub Copilot started suggesting additional sections such as CPU usage, network information, and running processes. That was the first time I saw how AI can speed up scripting when the structure is already clear.
Step 2: Use a Shared Folder with Virtual Machines
I used Vagrant virtual machines for testing. Instead of manually
copying scripts every time, I placed them in the synced folder.
Inside the VM, the scripts became available through the
/vagrant directory.
cd /vagrant
ls
This made the workflow much easier. I could edit the script in VS Code, save it, and then test it directly from the VM. It felt much closer to a real automation workflow than just writing shell commands in one local terminal.
Step 3: Improve the Script with Copilot
One of the most useful features was selecting the entire script and asking Copilot to improve it according to development best practices. This was not only about getting a better script, but also about learning what a better script looks like.
A common improvement it suggested was this line:
set -euo pipefail
At first this looked small, but it is actually very important in Bash scripting.
- -e makes the script exit immediately if a command fails.
- -u stops the script when an undefined variable is used.
- pipefail makes a pipeline fail if any command inside it fails.
This taught me that production-style scripts should fail early instead of silently continuing in a broken state.
Step 4: Learn Functions to Make Scripts Cleaner
Another big improvement was using functions. Instead of repeating the same echo lines and setup blocks, I learned to group reusable tasks into functions.
log() {
echo "########################"
echo "$1"
echo "########################"
}
install_dependencies() {
log "Installing packages"
yum install -y httpd wget unzip
}
With functions, the script became much easier to read. Each part had a clear purpose, and the main execution section simply called those functions in order.
main() {
install_dependencies
setup_webfiles
start_service
}
main
This was one of the most useful structural lessons for me. Instead of writing one long script from top to bottom, I learned to think in blocks of responsibility.
Step 5: Handle Variables Safely
I also learned that variable usage in Bash can be risky if you are not careful. For example, writing this is not ideal:
yum install $PACKAGE
A better version is:
yum install "$PACKAGE"
The double quotes help prevent word splitting and globbing issues. This may seem minor, but it can prevent weird bugs, especially when variables come from user input or contain spaces.
Step 6: Validate Directories and Files Before Running Commands
One great lesson was that scripts should not assume everything is valid. For example, a command like this can be dangerous:
cd $TEMP_DIR
If the variable is empty or the path is wrong, the script may continue running in the wrong location. The safer approach is:
cd "$TEMP_DIR" || exit 1
This means the script will stop immediately if it cannot change to the expected directory.
The same idea applies to checking files before using them:
if [ ! -f "$HOSTS_FILE" ]; then
echo "Hosts file not found"
exit 1
fi
Step 7: Use Modern Bash Syntax
Some older Bash syntax still works, but there are cleaner and more recommended ways to write things today. A good example is command substitution.
Older style:
DATE=`date`
Recommended style:
DATE=$(date)
The newer syntax is easier to read and better when commands become more complex.
Step 8: Handle User Input More Carefully
When reading user input, I learned that this is not the safest form:
read name
A better version is:
read -r name
Using -r helps prevent
backslashes from being interpreted in unexpected ways. This is
one of those small improvements that makes input handling safer.
Step 9: Move from Single-Machine Scripts to Multi-Host Automation
One of the most interesting parts of this learning journey was working with a remote setup script that could deploy changes to multiple machines. This was much closer to real DevOps work.
The script first read a list of hosts from a file and stored them in an array:
mapfile -t hosts < hosts.txt
Then it looped over each host:
for host in "${hosts[@]}"; do
echo "Working on $host"
done
This was especially useful because I learned why arrays and loops matter in automation. Instead of repeating the same steps manually, the script can apply them server by server.
Step 10: Copy and Execute Scripts on Remote Machines
The remote deployment workflow used tools like
scp and
ssh. The basic idea was
simple:
scp setup.sh user@$host:/tmp/
ssh user@$host "bash /tmp/setup.sh"
This means the automation script can copy another script to a remote machine and then run it there. That felt like an important shift from basic Bash practice to actual environment setup automation.
Step 11: Add OS-Based Logic
Another useful lesson was that not all Linux systems are the same. Package names and package managers may differ between Ubuntu and RPM-based systems.
if grep -qi ubuntu /etc/os-release; then
PACKAGE="apache2"
else
PACKAGE="httpd"
fi
This kind of operating system check makes scripts more flexible and reusable across environments.
Step 12: Use AI to Scaffold Bigger Projects, But Think Critically
One of the most impressive parts of the learning process was asking Copilot to generate a complete Tomcat setup project. The request included two setup scripts, one for Ubuntu and one for RPM-based systems, plus a deployment script that reads hosts from a file.
AI was able to generate a full project structure quickly, including a README and deployment flow. But the biggest lesson was not that AI can generate code. The real lesson was that I still needed to review it, question it, and test it in my VMs before trusting it.
What I Learned Overall
This experience helped me understand Bash scripting in a much more practical way. I did not just learn commands. I learned how to make scripts safer, more structured, and more reusable. I also learned that GitHub Copilot is most useful when I already understand the problem I am trying to solve.
In short, these were my biggest takeaways:
- Use Bash for real automation tasks, not just command practice.
- Structure scripts with functions and a clear main execution flow.
- Quote variables and validate files or directories before acting.
-
Use safer Bash practices like
set -euo pipefailandread -r. - Use arrays and loops when working with multiple hosts.
- Let AI help, but always verify and test everything yourself.
Final Thoughts
This was more than a Bash lesson for me. It was a lesson in how modern engineers work: using tools like AI to move faster, while still relying on their own judgment, testing, and debugging skills.
As I continue building my DevOps and backend skills, this kind of practical learning is exactly what I want more of: write, improve, test, break, fix, and understand.