{"id":465,"date":"2026-09-05T12:20:18","date_gmt":"2026-09-05T12:20:18","guid":{"rendered":"https:\/\/www.vps.tc\/blog\/?p=465"},"modified":"2026-09-14T15:21:51","modified_gmt":"2026-09-14T15:21:51","slug":"what-is-bash-scripting-linux-automation","status":"publish","type":"post","link":"https:\/\/www.vps.tc\/blog\/en\/what-is-bash-scripting-linux-automation\/","title":{"rendered":"What Is Bash Scripting? A Guide to Linux Automation"},"content":{"rendered":"<div class=\"aiw-toc\" style=\"border:1px solid #dbe3ea;border-radius:8px;padding:16px 20px;margin:0 0 28px\"><strong>Table of Contents<\/strong><\/p>\n<ol style=\"margin:10px 0 0;padding-left:22px\">\n<li><a href=\"#what-bash-scripting-does-for-a-linux-server\">What Bash Scripting Does for a Linux Server<\/a><\/li>\n<li><a href=\"#the-building-blocks-of-a-bash-script\">The building blocks of a Bash script<\/a><\/li>\n<li><a href=\"#variables-and-command-substitution\">Variables and command substitution<\/a><\/li>\n<li><a href=\"#conditions-and-exit-codes\">Conditions and exit codes<\/a><\/li>\n<li><a href=\"#functions-loops-and-repeated-work\">Functions, loops, and repeated work<\/a><\/li>\n<li><a href=\"#a-practical-automation-example-a-disk-usage-alert\">A practical automation example: a disk usage alert<\/a><\/li>\n<li><a href=\"#scheduled-bash-tasks-with-cron\">Scheduled Bash tasks with cron<\/a><\/li>\n<li><a href=\"#input-validation-and-safer-bash-habits\">Input validation and safer Bash habits<\/a><\/li>\n<li><a href=\"#when-bash-should-give-way-to-python-or-ansible\">When Bash should give way to Python or Ansible<\/a><\/li>\n<li><a href=\"#testing-and-monitoring-your-script\">Testing and monitoring your script<\/a><\/li>\n<li><a href=\"#frequently-asked-questions\">Frequently asked questions<\/a><\/li>\n<\/ol>\n<\/div>\n<h2 id=\"what-bash-scripting-does-for-a-linux-server\">What Bash Scripting Does for a Linux Server<\/h2>\n<p>At 3 a.m., a repeated command stops being a small task. It becomes a maintenance problem. Checking a few files, measuring disk usage, archiving old logs, or restarting services in a particular order can look harmless once. Repeated often enough, these jobs waste time and leave room for mistakes.<\/p>\n<p><strong>What is Bash scripting?<\/strong> It is a way to combine commands, variables, and control structures in a file that Bash can execute. You can turn manual steps into a sequence, add conditions, produce useful output, and run the work on a schedule with tools such as cron.<\/p>\n<p>I still use Bash for small systems administration jobs. Not every task needs a YAML file. My rule is simple: after doing something twice, I ask what would happen if another person had to run it tomorrow. If the answer is unclear, I write a script and make its behavior safer before repeating the task by hand.<\/p>\n<h2 id=\"the-building-blocks-of-a-bash-script\">The building blocks of a Bash script<\/h2>\n<p>Bash scripts commonly use a <code>.sh<\/code> extension. It is not required; Linux cares about the file contents and how you invoke it. The extension does tell the next person fairly quickly that the file contains a shell script.<\/p>\n<p>At the top of the file, specify the interpreter:<\/p>\n<pre><code>#!\/usr\/bin\/env bash\n\nprintf 'Hello, %sn' \"$USER\"<\/code><\/pre>\n<p>The first line is the shebang. <code>\/usr\/bin\/env bash<\/code> looks for Bash in the system&#8217;s <code>PATH<\/code>. Some distributions also provide Bash at <code>\/bin\/bash<\/code>. Using <code>env<\/code> can make the script a little easier to move between systems, provided Bash is installed and available in <code>PATH<\/code>.<\/p>\n<p>I use <code>printf<\/code> because its formatting behavior is more predictable than <code>echo<\/code>. For one short message, either is fine. Scripts tend to collect small ambiguities as they grow.<\/p>\n<h3>Creating and running your first script<\/h3>\n<p>Open a new file:<\/p>\n<pre><code>vim disk-check.sh<\/code><\/pre>\n<p>Add these lines:<\/p>\n<pre><code>#!\/usr\/bin\/env bash\n\nset -u\n\nprintf 'Hostname: %sn' \"$(hostname)\"\nprintf 'Date: %sn' \"$(date '+%Y-%m-%d %H:%M:%S')\"\nprintf 'Disk usage:n'\ndf -h \/<\/code><\/pre>\n<p><code>set -u<\/code> makes the script fail when it tries to use an unset variable. It does not catch every possible error, but it helps me find spelling mistakes early.<\/p>\n<p>Make the file executable and run it:<\/p>\n<pre><code>chmod +x disk-check.sh\n.\/disk-check.sh<\/code><\/pre>\n<p>The <code>-a<\/code> flag is not involved here; <code>+x<\/code> adds execute permission. You can also invoke the file directly with Bash:<\/p>\n<pre><code>bash disk-check.sh<\/code><\/pre>\n<p><code>.\/disk-check.sh<\/code> requires execute permission and a usable shebang. <code>bash disk-check.sh<\/code> explicitly runs the file with Bash. If you use Bash-specific arrays or <code>[[ ... ]]<\/code>, do not invoke the script with <code>sh<\/code>; on some systems, <code>sh script.sh<\/code> starts a different shell.<\/p>\n<h2 id=\"variables-and-command-substitution\">Variables and command substitution<\/h2>\n<p>Scripts become useful when fixed text is separated from values that change:<\/p>\n<pre><code>#!\/usr\/bin\/env bash\n\nBACKUP_DIR=\"\/var\/backups\"\nHOST=\"$(hostname -s)\"\nNOW=\"$(date '+%Y%m%d-%H%M%S')\"\n\nprintf 'Server: %sn' \"$HOST\"\nprintf 'Backup directory: %sn' \"$BACKUP_DIR\"\nprintf 'Time label: %sn' \"$NOW\"<\/code><\/pre>\n<p>Do not put spaces around the equals sign when assigning a variable. Also, do not skip the double quotes when expanding one. If a path contains spaces, <code>\"$BACKUP_DIR\"<\/code> and <code>$BACKUP_DIR<\/code> do not behave the same way. I avoid spaces in server paths, but I keep the quoting habit anyway.<\/p>\n<p>Use <code>$(...)<\/code> to store command output. The older backtick syntax still works, but nested commands are harder to read with it. Readability matters when you are debugging from a console with cold coffee beside you.<\/p>\n<h3>Accepting arguments<\/h3>\n<p>Editing a path inside the script every time you run it is not practical. Command-line arguments solve that:<\/p>\n<pre><code>#!\/usr\/bin\/env bash\n\nif [[ $# -ne 1 ]]; then\n    printf 'Usage: %s DIRECTORYn' \"$0\" &gt;&amp;2\n    exit 1\nfi\n\nTARGET=\"$1\"\n\nif [[ ! -d \"$TARGET\" ]]; then\n    printf 'Error: directory not found: %sn' \"$TARGET\" &gt;&amp;2\n    exit 2\nfi\n\ndu -sh -- \"$TARGET\"<\/code><\/pre>\n<p><code>$#<\/code> is the number of arguments, <code>$1<\/code> is the first argument, and <code>$0<\/code> is the script name. The <code>--<\/code> tells the command that options have ended, so a path beginning with a hyphen is less likely to be interpreted as an option. A small detail. In file operations, small details can become night-shift-sized details.<\/p>\n<h2 id=\"conditions-and-exit-codes\">Conditions and exit codes<\/h2>\n<p>Reliable automation needs more than a list of commands. When one step fails, you need to decide whether the next step should run.<\/p>\n<pre><code>#!\/usr\/bin\/env bash\n\nSERVICE=\"nginx\"\n\nif systemctl is-active --quiet \"$SERVICE\"; then\n    printf '%s is running.n' \"$SERVICE\"\nelse\n    printf '%s is not running.n' \"$SERVICE\" &gt;&amp;2\n    exit 1\nfi<\/code><\/pre>\n<p><code>systemctl is-active --quiet<\/code> returns an exit code without unnecessary output. In the shell, <code>0<\/code> means success and a non-zero value means failure. That feels backwards at first. It is not.<\/p>\n<p>Here are common file-test operators:<\/p>\n<table>\n<thead>\n<tr>\n<th>Test<\/th>\n<th>Meaning<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>-f<\/code><\/td>\n<td>Does a regular file exist?<\/td>\n<\/tr>\n<tr>\n<td><code>-d<\/code><\/td>\n<td>Does a directory exist?<\/td>\n<\/tr>\n<tr>\n<td><code>-r<\/code><\/td>\n<td>Is it readable?<\/td>\n<\/tr>\n<tr>\n<td><code>-w<\/code><\/td>\n<td>Is it writable?<\/td>\n<\/tr>\n<tr>\n<td><code>-s<\/code><\/td>\n<td>Does the file exist and have a size greater than zero?<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>For scripts where several commands must succeed, you will often see this near the beginning:<\/p>\n<pre><code>set -Eeuo pipefail<\/code><\/pre>\n<p><code>-e<\/code> exits on a failed command, <code>-u<\/code> treats unset variables as errors, and <code>pipefail<\/code> exposes a failure anywhere in a pipeline. <code>-E<\/code> makes an <code>ERR<\/code> trap behave more consistently inside functions and subshells.<\/p>\n<p>Do not add these options blindly. For example, <code>grep<\/code> returns a non-zero status when it finds no matches. That may be expected information rather than a script failure. Know the intended result of each command first.<\/p>\n<h2 id=\"functions-loops-and-repeated-work\">Functions, loops, and repeated work<\/h2>\n<p>As a script gets longer, I use functions instead of copying the same code into several places:<\/p>\n<pre><code>log() {\n    printf '[%s] %sn' \"$(date '+%Y-%m-%d %H:%M:%S')\" \"$*\"\n}\n\nfail() {\n    log \"ERROR: $*\" &gt;&amp;2\n    exit 1\n}\n\nlog 'Starting checks'\n[[ -d \/var\/log ]] || fail '\/var\/log was not found'\nlog 'Checks completed'<\/code><\/pre>\n<p>The <code>log<\/code> function adds a timestamp to every message. That is useful when investigating when a cron job actually ran. You can follow the output through <code>journalctl<\/code> or a log file.<\/p>\n<p>Loops handle the same operation on several files:<\/p>\n<pre><code>for file in \/var\/log\/*.log; do\n    [[ -e \"$file\" ]] || continue\n    printf '%s: ' \"$file\"\n    wc -l &lt; \"$file\"\ndone<\/code><\/pre>\n<p>The <code>[[ -e \"$file\" ]]<\/code> check prevents an unmatched glob from remaining as literal text when there are no matching files. I once left that check out of an older script. It tried to process a file literally named <code>\/var\/log\/*.log<\/code>. No outage, just an unnecessary alert. Monitoring noise is its own kind of failure.<\/p>\n<h2 id=\"a-practical-automation-example-a-disk-usage-alert\">A practical automation example: a disk usage alert<\/h2>\n<p>An alert that only says &#8220;the disk is full&#8221; is not very helpful. I want the affected filesystem and a quick view of the largest directories:<\/p>\n<pre><code>#!\/usr\/bin\/env bash\n\nset -Eeuo pipefail\n\nTHRESHOLD=80\nMOUNTPOINT=\"\/\"\n\nusage=$(df -P \"$MOUNTPOINT\" | awk 'NR==2 {gsub(\"%\", \"\", $5); print $5}')\n\nif (( usage &gt;= THRESHOLD )); then\n    printf 'WARNING: %s is at %s%% usage.n' \"$MOUNTPOINT\" \"$usage\" &gt;&amp;2\n    printf 'Largest directories:n' &gt;&amp;2\n    du -xhd1 \"$MOUNTPOINT\" 2&gt;\/dev\/null | sort -h | tail -n 8 &gt;&amp;2\n    exit 1\nfi\n\nprintf 'Disk is normal: %s%% used.n' \"$usage\"<\/code><\/pre>\n<p><code>df -P<\/code> makes the output more consistent for scripts. <code>awk<\/code> removes the percent sign and extracts the number. <code>du -x<\/code> stays on the current filesystem instead of crossing into another mount, which helps prevent an unexpected remote filesystem from making the check slow.<\/p>\n<p>One night at around 3 a.m., <code>\/var<\/code> was close to full. My first instinct was to restart the service. I had to train that instinct out of myself. <code>ncdu<\/code> showed that an application&#8217;s log rotation configuration had been disabled, so I fixed the logrotate rule instead. The Bash script was not the diagnosis; it turned the right check into something repeatable.<\/p>\n<h2 id=\"scheduled-bash-tasks-with-cron\">Scheduled Bash tasks with cron<\/h2>\n<p>Manual execution is fine while testing. For regular checks or backups, cron can run the script on a schedule. Test it first as the same user cron will use:<\/p>\n<pre><code>sudo -u backup \/usr\/local\/sbin\/disk-check.sh<\/code><\/pre>\n<p>Then open that user&#8217;s crontab:<\/p>\n<pre><code>crontab -e<\/code><\/pre>\n<p>This runs the script every 15 minutes:<\/p>\n<pre><code>*\/15 * * * * \/usr\/local\/sbin\/disk-check.sh &gt;&gt; \/var\/log\/disk-check.log 2&gt;&amp;1<\/code><\/pre>\n<p>The cron environment is smaller than your interactive shell. <code>PATH<\/code> may differ, the working directory may not be what you expect, and environment variables may be missing. Use absolute paths for critical commands or define a suitable <code>PATH<\/code>:<\/p>\n<pre><code>PATH='\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin'\nexport PATH<\/code><\/pre>\n<p>A scheduling change once taught me this the uncomfortable way. I was editing what I believed was a staging crontab when tab completion showed the hostname: production. Since then, I check the hostname before commands and use a red prompt for critical sessions. Thirty seconds is cheap.<\/p>\n<h3>Preventing overlapping runs<\/h3>\n<p>For backups or long-running reports, use <code>flock<\/code>:<\/p>\n<pre><code>#!\/usr\/bin\/env bash\nset -Eeuo pipefail\n\nexec 9&gt;\/run\/lock\/my-backup.lock\nflock -n 9 || {\n    printf 'Another backup is already running.n' &gt;&amp;2\n    exit 1\n}\n\nprintf 'Backup started.n'\nRun the long backup command here<\/code><\/pre>\n<p>The script obtains a lock through file descriptor 9. The lock is released when the script exits. That prevents two copies of the same cron job from writing to the destination at once.<\/p>\n<h2 id=\"input-validation-and-safer-bash-habits\">Input validation and safer Bash habits<\/h2>\n<p>If a Bash script runs with root privileges, adding user input directly to a command is risky. <code>eval<\/code> is especially dangerous because it treats text as shell code.<\/p>\n<p>For a script that accepts a path, at least do the following:<\/p>\n<ul>\n<li>Validate the expected number of arguments.<\/li>\n<li>Check that the file or directory exists.<\/li>\n<li>For deletion, verify separately that the target is not empty.<\/li>\n<li>Use variables inside double quotes.<\/li>\n<li>Offer a dry-run mode before deleting or moving anything.<\/li>\n<li>Use root only when necessary.<\/li>\n<\/ul>\n<p>This pattern deserves suspicion:<\/p>\n<pre><code>rm -rf \"$TARGET\"\/*<\/code><\/pre>\n<p>If <code>TARGET<\/code> is empty, the resulting command can expand to <code>rm -rf \/*<\/code>. That is not a typo I want to discover from a monitoring alert. At minimum, add a guard:<\/p>\n<pre><code>if [[ -z \"${TARGET:-}\" || \"$TARGET\" == \"\/\" ]]; then\n    printf 'Unsafe target; operation cancelled.n' &gt;&amp;2\n    exit 1\nfi<\/code><\/pre>\n<p>This check is not a complete security boundary. It catches missing arguments, incorrect variables, and rushed invocations. Before deleting anything on production, I still check the hostname. The autocomplete result showing the production hostname once stopped me at the last second.<\/p>\n<h2 id=\"when-bash-should-give-way-to-python-or-ansible\">When Bash should give way to Python or Ansible<\/h2>\n<p>Bash is a good fit for combining a few commands on one Linux server, handling files and logs, and checking service states. When data structures become complicated, error handling grows involved, or HTTP APIs dominate the task, Python is often easier to maintain.<\/p>\n<p>For repeatable configuration across several servers, Ansible gives me a better layer. My rule is to turn a task into a playbook after doing it manually twice. I do not turn a one-line package installation into YAML just because automation sounds more serious.<\/p>\n<table>\n<thead>\n<tr>\n<th>Need<\/th>\n<th>Better fit<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>File and service operations on one server<\/td>\n<td>Bash<\/td>\n<\/tr>\n<tr>\n<td>Complex data processing and API integration<\/td>\n<td>Python<\/td>\n<\/tr>\n<tr>\n<td>Repeatable configuration across several servers<\/td>\n<td>Ansible<\/td>\n<\/tr>\n<tr>\n<td>Small helper task in a deployment pipeline<\/td>\n<td>Bash or a pipeline tool<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>It can be tempting to turn every step from <strong><a href=\"https:\/\/www.vps.tc\/blog\/en\/launch-a-secure-vps-in-30-minutes-pro-admin-guide\/\">Launch a Secure VPS in 30 Minutes | Pro Admin Guide<\/a><\/strong> into a Bash script. SSH keys, firewall rules, users, and packages are all scriptable, but a script that is not safe to rerun may damage existing settings on its second run. Test each command on a system that already has part of the configuration.<\/p>\n<p>Cloud-init can also run Bash commands during first boot. The approach described in <strong><a href=\"https:\/\/www.vps.tc\/blog\/en\/what-is-cloud-init-automate-vps-provisioning\/\">What Is Cloud-Init? Automate VPS Provisioning<\/a><\/strong> is useful here. I usually keep a sizeable script in a separate version-controlled file instead of burying it inside cloud-init, and I make its logs easy to find.<\/p>\n<h2 id=\"testing-and-monitoring-your-script\">Testing and monitoring your script<\/h2>\n<p>A script that exits without an error is not necessarily doing the right thing. I test on a separate VPS or, more often, a temporary virtual machine in my Proxmox lab. A customer server is not a test environment.<\/p>\n<p>For debugging, use Bash&#8217;s tracing mode:<\/p>\n<pre><code>bash -x .\/disk-check.sh<\/code><\/pre>\n<p>This displays commands as they execute. Clean the output before sharing it if arguments contain passwords or tokens. You can also trace one section with <code>set -x<\/code> and <code>set +x<\/code>.<\/p>\n<p>Know where output goes. Under cron, standard output may be mailed on many systems; with the wrong configuration, a mail queue can grow quietly. For important scripts, redirect output explicitly to a log file or use a systemd timer and inspect the journal.<\/p>\n<p>Before I call a script ready, I ask:<\/p>\n<ul>\n<li>How will I know it completed successfully?<\/li>\n<li>If one step fails, should later steps run?<\/li>\n<li>What happens if two copies run at once?<\/li>\n<li>Where will logs be kept, and for how long?<\/li>\n<li>How can the operation be rolled back or safely repeated?<\/li>\n<\/ul>\n<p>These questions become real maintenance work once a cron job has been running for five years. Leave tomorrow&#8217;s version of yourself clear variable names, comments around dangerous steps, and a staging test. &#8220;It works on my server&#8221; is not a deployment plan.<\/p>\n<p>When I&#8217;m automating Linux tasks with Bash, I also make sure I understand admin privileges and account security, so I recommend reading [<a href=\"https:\/\/www.vps.tc\/blog\/en\/what-does-admin-mean-admin-definition-explained\/\">What Does Admin Mean? Admin Definition Explained<\/a>] for a clear overview.<\/p>\n<h2 id=\"frequently-asked-questions\">Frequently asked questions<\/h2>\n<h3>What is Bash scripting used for?<\/h3>\n<p>Bash scripting sequences Linux commands, automates file operations, checks services, and schedules backup or maintenance tasks. It remains practical for small and medium-sized systems administration jobs.<\/p>\n<h3>How do you run a Bash script?<\/h3>\n<p>Add execute permission and run <code>.\/script.sh<\/code>, or invoke it with <code>bash script.sh<\/code>. If the file uses Bash-specific syntax, use Bash rather than <code>sh script.sh<\/code>.<\/p>\n<h3>Should a Bash script run as root?<\/h3>\n<p>Only when it needs to. Granting commands the minimum required permissions reduces the impact of a mistake. When root is necessary, validate inputs carefully and protect deletion operations.<\/p>\n<h3>When should you choose Python or Ansible instead of Bash?<\/h3>\n<p>Python is usually a better fit for complicated data processing and API use. Ansible suits repeatable configuration across many servers. For a few dependable commands on one Linux server, Bash still does the job with less overhead.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>What is Bash scripting? Learn how Bash combines Linux commands into safer, repeatable automation for server checks, cron jobs, backups, and maintenance.<\/p>\n","protected":false},"author":2,"featured_media":463,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[5],"tags":[1619,1628,844,43,1622,1625,477],"class_list":["post-465","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-linux","tag-bash-en","tag-cron-en","tag-devops","tag-linux","tag-linux-automation","tag-shell-scripting","tag-system-administration"],"lang":"en","translations":{"en":465,"tr":464},"pll_sync_post":[],"_links":{"self":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/465","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/comments?post=465"}],"version-history":[{"count":2,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/465\/revisions"}],"predecessor-version":[{"id":679,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/465\/revisions\/679"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/media\/463"}],"wp:attachment":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/media?parent=465"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/categories?post=465"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/tags?post=465"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}