Introduction


In the previous post, Automating the installation of my Arch Linux environment, I talk about how I automated the installation of my Arch Linux environment through a script, which will be detailed in this article.

Therefore, the purpose of this post is to do a breakdown of that script, whose goal is to automate the entire Arch installation with the settings I usually use — from the Live ISO environment to a fully configured system, with or without a graphical interface.

To refresh our memory without having to reread the previous post, we can bring back the script's flow, since it's the most important part for this article.

text
Live ISO (root)
 └─ archinstall.sh
      ├─ sanity checks: root / internet
      ├─ user input: minimal install or with desktop environment
      ├─ user input: BIOS or UEFI boot mode
      ├─ detects disk, CPU vendor, virtualization
      ├─ asks for passwords + input: update keyrings? + shows the plan + confirmation
      ├─ partitions (parted) → formats → mounts
      ├─ (optional) updates archlinux-keyring
      ├─ pacstrap: base + microcode + desktop + virtualization packages
      ├─ genfstab
      └─ arch-chroot /mnt → chroot-setup.sh
              ├─ timezone, locale, keyboard layout, hostname
              ├─ enables NetworkManager
              ├─ sets passwords, creates user, configures sudo
              ├─ grub-install + grub-mkconfig
              ├─ enables LightDM/Cinnamon (if configured)
              └─ enables VM guest services (if detected)
 └─ unmounts → done, reboot

To avoid being verbose, since the previous post already covers these details, I'll summarize the requirements to run the script:

  • Arch Linux ISO booted. Preferably the current ISO available on the official website archlinux.org/download.
  • Internet connection
  • Have a way to get the script files onto the live ISO (via Git is the most direct way, cloning my repository, git clone https://github.com/xyz-leo/dotfiles)
  • Grant execution permission to the archinstall.sh and chroot-setup.sh files (chmod +x)
  • Run the script, answer the questions and wait for the installation to finish.

Now, let's break down the script's code and understand how it works.

Breakdown


Link to the script's source code: dotfiles/archinstall.

Shebang, set -euo, SCRIPT_DIR, source and logs

These are the first lines of the script and are quite important:

bash
#!/usr/bin/env bash

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/config.sh"

log()  { echo -e "\n==> $*"; }
err()  { echo "ERROR: $*" >&2; }

These snippets represent a common pattern in Bash scripts, so it's worth truly understanding them.

TL;DR:

  • #!/usr/bin/env bash → tells the system to run the script with the Bash available in the user's environment. This is the shebang.
  • set -euo pipefail → enables a safe mode for the script in case of failures.
    • -e → terminates the script if a command fails.
    • -u → raises an error when using an undefined variable.
    • -o pipefail → makes a pipeline fail if any command in it fails (by default, a | pipeline returns the exit code of only the last command).
  • SCRIPT_DIR=... → discovers the directory where the script itself is located, regardless of where it was run from.
  • source "$SCRIPT_DIR/config.sh" → imports another Bash file, allowing variables and functions to be reused.
  • log() and err() → helper functions to standardize messages. err() sends the output to stderr, while log() prints regular messages.

Now, some of the important details.

First, in general, $ in Bash means:

  • $1 → first argument of the script/function.
  • $2 → second argument.
  • $* → all arguments.
  • $@ → all arguments (preserving each one separately).
  • $# → number of arguments.
  • $? → exit code of the last command.
  • $$ → PID of the current process.
  • $0 → script name.
  • ${...} → more explicit form of expanding variables (${HOME}, ${name}, etc.).
  • Also appears in command substitution, e.g.: $(pwd) → Here the $ says: "run the command inside the parentheses and substitute this with its output".

In summary, Bash expands expressions started with $, replacing them with the corresponding value before executing the command.

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" → this line is made up of several small parts.

  • BASH_SOURCE → An internal Bash array containing the path of the scripts currently being executed; BASH_SOURCE[0] is the current Bash file.
  • dirname → Extracts only the directory from the given path, e.g.: /root/dotfiles/archinstall
  • cd → Enters that directory.
  • pwd → Gets the absolute path of the current directory. Since cd produces no output, pwd is needed so that $(...) captures that path and assigns it to the SCRIPT_DIR variable.
  • $(...) → Executes the command inside the parentheses and replaces the expression with the produced output.

err() { echo "ERROR: $*" >&2; } → helper function to redirect a message to stderr.

  • >&2 → redirects the output to stderr (the standard stream intended for a process's error messages).
    • > → redirects the output.
    • & → indicates that the following number represents a file descriptor.
    • 2 → is the stderr file descriptor.

check_root and check_internet

One of the first things the script does is check whether it's being run as root and whether there's an internet connection. This is necessary because the installation requires administrative privileges to run various commands and internet access to download packages.

bash
check_root() {
  [[ $EUID -eq 0 ]] || { err "Must run as root."; exit 1; }
}

check_internet() {
  ping -c1 -W2 archlinux.org &>/dev/null || { err "No internet connection."; exit 1; }
}

check_root() → Checks whether the user running the script is root.

  • EUID → An internal Bash variable (Effective User ID) that contains the identifier of the process's effective user. The root user always has UID 0.
  • [[ $EUID -eq 0 ]] → Literally means "is the value of $EUID equal to 0?" — i.e., checks whether the process is running with root permissions.
  • || { err...}|| literally means "or" — i.e., runs the block on the right if the expression on the left fails. In this case, it shows the error message and terminates the process with exit code 1.

check_internet() → Checks whether the script has internet access.

  • ping → sends an ICMP packet to test communication with the given host.
  • -c1 → sends only 1 packet.
  • -W2 → waits at most 2 seconds for a response.
  • &>/dev/null → discards both the normal output (stdout) and the error output (stderr).

For context on comparing numeric operators in Bash, the main ones are:

  • -eq → equal
  • -ne → not equal
  • -gt → greater
  • -ge → greater or equal
  • -lt → less than
  • -le → less or equal

part_suffix and detect_disk

These two functions are related to disk operations.

bash
part_suffix() {
  local disk="$1" num="$2"
  if [[ "$disk" =~ [0-9]$ ]]; then
    echo "${disk}p${num}"
  else
    echo "${disk}${num}"
  fi
}

detect_disk() {
  [[ -n "$DISK" ]] && return

  mapfile -t disks < <(lsblk -dnpo NAME,TYPE | awk '$2=="disk"{print $1}')
  if [[ ${#disks[@]} -eq 0 ]]; then
    err "No disks found."
    exit 1
  elif [[ ${#disks[@]} -eq 1 ]]; then
    DISK="${disks[0]}"
  else
    echo "Multiple disks found:"
    lsblk -dpo NAME,SIZE,MODEL
    local i=1
    for d in "${disks[@]}"; do
      echo "  $i) $d"
      ((i++))
    done
    read -rp "Select disk number: " choice
    DISK="${disks[$((choice - 1))]}"
  fi
}

part_suffix() → Responsible for building a string with the correct disk name and partition, e.g.: /dev/sda1 or /dev/nvme0n1p1.

  • local disk="$1" num="$2" → In Bash, variables in functions are global by default, unlike most languages, so we use local. We assign the two parameters received by the function to the local variables disk and num.
  • if [[ "$disk" =~ [0-9]$ ]]; then → Uses a regex (=~ [0-9]$) to check whether the disk name ($disk) ends with a digit. In those cases, partition naming requires a p before the number (e.g. /dev/nvme0n1p1). Otherwise, it's enough to concatenate the number (e.g. /dev/sda1)

detect_disk() → Detects the disk that will be used during installation and stores its path in the global variable DISK.

I hadn't yet talked about conditional expressions [[ ]], they basically evaluate whether the condition is true or false.

  • [[ -n "$DISK" ]] && return → The -n flag means non-empty. So return will only run if the expression on the left is true, i.e., if DISK already has a value. In this case, it means the variable was already defined (in config.sh) and there's no need to detect the disk to be used again. This pattern is known as a guard clause, i.e., exiting a function early when a specific condition is met.
  • mapfile -t disks < <(lsblk -dnpo NAME,TYPE | awk '$2=="disk"{print $1}') → In summary, this line combines a chain of tools to read multiple lines of text and store each of them in an array element.
    • Example: if the input is /dev/sda and /dev/nvme0n1, after running mapfile, the array will be disks[0]="/dev/sda" and disks[1]="/dev/nvme0n1".
    • -t → Flag that removes the trailing newline (\n) from each line read.
    • lsblk -dnpo NAME,TYPE → Lists block devices (HDs, SSDs, flash drives, NVMEs...). Each letter represents a flag: -d shows only disks (without partitions); -n omits the header; -p shows the full device path; -o defines the columns to display (in this case, NAME and TYPE). The output will be something like /dev/sda disk, /dev/nvme0n1 disk and /dev/sda1 part.
    • awk '$2=="disk"{print $1}' → When the second column is disk, prints the first column. In practice, this filters the lsblk output, keeping only disks and discarding partitions, CD-ROMs and other devices.
    • mapfile → Reads the lines produced by the previous command and builds the disks array.
  • if, elif and else → After filling the disks array, the script checks how many disks were found.
    • If the disks array is empty, it terminates the script because no disk was found.
    • If the array contains only one element, it uses that disk automatically, since there's no other option.
    • Otherwise, it means more than one disk was found. The script shows the list of disks and asks the user to choose which one to use.
  • read -rp "Select disk number: " choiceread reads user input; -r reads it literally, without interpreting backslashes (\); -p shows a prompt before waiting for input (in this case, "Select disk number: "); choice is the variable where the answer will be stored.
  • DISK="${disks[$((choice - 1))]}" → Assigns the disk chosen by the user to the DISK variable. The - 1 exists because the list shown to the user starts at 1, while Bash array indexes start at 0.

swap_size_mib, detect_microcode and detect_virt

swap_size_mib() calculates the size of the swap partition that will be created later; detect_microcode() and detect_virt() detect information about the CPU and the virtualization environment using, respectively, /proc/cpuinfo and systemd-detect-virt.

bash
swap_size_mib() {
  local ram_mib
  ram_mib=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
  if (( ram_mib <= 2048 )); then
    echo $(( ram_mib * 2 ))
  elif (( ram_mib <= 8192 )); then
    echo "$ram_mib"
  else
    echo 8192
  fi
}

detect_microcode() {
  local vendor
  vendor=$(awk -F: '/vendor_id/{print $2; exit}' /proc/cpuinfo | tr -d ' ')
  case "$vendor" in
    GenuineIntel) echo "intel-ucode" ;;
    AuthenticAMD) echo "amd-ucode" ;;
    *) echo "" ;;
  esac
}

detect_virt() {
  # systemd-detect-virt prints "none" on bare metal too, but still exits
  # non-zero in that case (its exit code is a separate is-virtualized
  # boolean) -- naively `|| echo "none"` on that double-prints ("none\nnone").
  local out
  out=$(systemd-detect-virt 2>/dev/null) || true
  echo "${out:-none}"
}

swap_size_mib() → Swap is an area of the disk used as an extension of RAM. Since the disk is much slower than RAM, the system only resorts to it when truly necessary. This function calculates the size of the swap partition based on the amount of RAM available. The logic is simple: the smaller a machine's RAM, the higher the chance it will be fully consumed during usage spikes, causing swap to be used to keep the system running. On machines with more memory (for example, above 16 GiB), an 8 GiB swap is usually enough, since it's less likely that all the RAM will be used.

  • ram_mib=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo) → Again the awk program is used to read a specific line and apply an action. In this case, it reads the /proc/meminfo file, looks for the MemTotal line, which contains the machine's total RAM, divides that value by 1024 (since MemTotal is given in kB) and returns the result in MiB, assigning it to the local variable ram_mib.
  • if, elif and else → Check the value of ram_mib and, based on it, define the swap partition size in MiB.

detect_microcode() → This function detects the CPU vendor to decide which microcode package will be installed during pacstrap. Currently, only Intel and AMD CPUs are handled. If no known vendor is identified, no additional package is installed.

  • vendor=$(awk -F: '/vendor_id/{print $2; exit}' /proc/cpuinfo | tr -d ' ') → Just like in swap_size_mib(), awk is used to read a system file. In this case, it looks for the line containing vendor_id in /proc/cpuinfo, extracts the CPU vendor and assigns the result to the local variable vendor.
    • -F: → Sets : as the column separator (Field Separator).
    • exit → Terminates awk after finding the first occurrence of vendor_id, since it only needs to read once.
    • | tr -d ' ' → The tr program removes all spaces from the string returned by awk.
  • case "$vendor" in ... → Checks the value of vendor. If it's GenuineIntel, returns the Intel microcode package; if it's AuthenticAMD, returns the AMD one; otherwise, returns an empty string.

detect_virt() → Detects whether the system is running inside a virtual machine. This information is later used to install the appropriate packages and enable the appropriate services for the detected environment.

  • out=$(systemd-detect-virt 2>/dev/null) || true → Runs the systemd-detect-virt program, which tries to identify whether the system is running in a virtualized environment, and stores the output in the out variable.
    • 2>/dev/null → Redirects stderr to /dev/null, discarding error messages.
    • || true → If systemd-detect-virt returns an error (a code other than 0), runs true, causing the script to continue normally instead of stopping. > Note: during testing, systemd-detect-virt identified my laptop (physical hardware) as a virtualized environment. Since this function fully trusts the output of that program, this false positive causes the rest of the script to install the packages and enable the services corresponding to the detected environment. In practice, this doesn't cause any relevant problems, since those packages are small, don't interfere with the system's operation, and only add support for virtualized environments.

prompt, virt_packages_for, de_packages_for, update_keyring and confirm_plan functions

Notice that, up to this point, the script has stayed in a discovery phase: identifying the system, the environment it's running in, who ran it, which resources are available, and handling possible edge cases before making any changes. Only after this stage does the actual installation begin.

We'll start with the prompt functions, which are the ones that ask for user input. These functions exist to collect or validate the last settings needed before the destructive stage of the installation, allowing both an interactive mode and a fully automated mode.

prompt functions:

bash
prompt_boot_mode() {
  if [[ "$AUTO_CONFIRM" == true ]]; then
    BOOT_MODE="uefi"
    return
  fi

  # echo omitted
  ...
  read -rp "Choice [0/1] (default: 1): " choice
  choice="${choice:-1}"

  case "$choice" in
    0) BOOT_MODE="bios" ;;
    1) BOOT_MODE="uefi" ;;
    *) err "Invalid choice '$choice'."; exit 1 ;;
  esac

  if [[ "$BOOT_MODE" == "uefi" && ! -d /sys/firmware/efi/efivars ]]; then
    log "Warning: this session doesn't look like it's booted in UEFI mode (/sys/firmware/efi/efivars missing) -- grub-install may fail later."
  fi
}

prompt_install_type() {
  [[ "$AUTO_CONFIRM" == true ]] && return

  local default=1
  [[ "$DESKTOP_ENV" == "none" ]] && default=0

  # echo omitted
  ...
  read -rp "Choice [0/1] (default: $default): " choice
  choice="${choice:-$default}"

  case "$choice" in
    0) DESKTOP_ENV="none" ;;
    1) DESKTOP_ENV="cinnamon" ;;
    *) err "Invalid choice '$choice'."; exit 1 ;;
  esac
}

prompt_update_keyring() {
  UPDATE_KEYRING=false
  [[ "$AUTO_CONFIRM" == true ]] && return

  read -rp "Update keyrings before installing packages? Only needed if pacstrap fails on an old ISO. [y/N]: " ans
  [[ "$ans" =~ ^[Yy] ]] && UPDATE_KEYRING=true
  return 0
}

prompt_passwords() {
  local pw1 pw2

  read -rsp "Root password: " pw1; echo
  read -rsp "Confirm root password: " pw2; echo
  [[ "$pw1" == "$pw2" ]] || { err "Passwords do not match."; exit 1; }
  ROOT_PASSWORD="$pw1"

  read -rsp "Password for user $USERNAME: " pw1; echo
  read -rsp "Confirm password for $USERNAME: " pw2; echo
  [[ "$pw1" == "$pw2" ]] || { err "Passwords do not match."; exit 1; }
  USER_PASSWORD="$pw1"
}

Two patterns present at the start of the following prompt functions are conditions similar to if [[ "$AUTO_CONFIRM" == true ]]; then and calls to read -rp. The first checks whether the AUTO_CONFIRM variable is enabled; if it is, all interactive questions are skipped and the installation uses the values preconfigured in config.sh. read -rp, on the other hand, is used to ask for user input, storing the answer in a variable that will be used later by the script. Another pattern present in these functions is the use of case, used to handle different possible user inputs. The choice variable receives that input and, depending on the value given, the script performs a specific action, such as setting the boot mode (BIOS or UEFI) or choosing the install type (minimal or desktop). If the input doesn't match any expected option, the script treats it as an invalid option.

virt_packages_for() and de_packages_for():

bash
virt_packages_for() {
  case "$1" in
    oracle)    echo "virtualbox-guest-utils" ;;
    kvm|qemu)  echo "qemu-guest-agent" ;;
    *)         echo "" ;;
  esac
}

de_packages_for() {
  case "$DESKTOP_ENV" in
    cinnamon)
      echo "xorg-server xorg-xinit lightdm lightdm-gtk-greeter cinnamon gnome-terminal gvfs gvfs-smb network-manager-applet xdg-user-dirs"
      ;;
    none) echo "" ;;
    *) err "Unknown DESKTOP_ENV: $DESKTOP_ENV"; exit 1 ;;
  esac
}

These two functions share similar logic: they receive a setting and return which additional packages should be installed during pacstrap.

virt_packages_for() → Responsible for returning the necessary packages depending on the detected virtualized environment.

  • case "$1" in$1 represents the first argument passed to the function. In this case, it receives the value of DETECTED_VIRT, which contains the detected virtualization type.
  • oracle) → If VirtualBox is detected, returns the virtualbox-guest-utils package.
  • kvm|qemu) → If KVM or QEMU is detected, returns the qemu-guest-agent package.
  • *) → If none of the previous cases match, returns an empty string, meaning no extra package needs to be installed.

de_packages_for() → Follows the same idea, but related to the graphical environment chosen by the user.

  • case "$DESKTOP_ENV" in → Checks the value of the DESKTOP_ENV variable, which defines whether the installation will have a desktop environment or be minimal.
  • cinnamon) → Returns the list of packages needed to install the Cinnamon environment, including graphical server, login manager and some utilities.
  • none) → Returns an empty string, since an installation without a graphical environment doesn't need those packages.
  • *) → If the value of DESKTOP_ENV isn't recognized, shows an error and terminates the script.

In summary, these functions just bridge a detected/chosen setting and the packages that need to be installed later by pacstrap.

update_keyring() and confirm_plan():

bash
update_keyring() {
  log "Updating archlinux-keyring"
  pacman -Sy --noconfirm archlinux-keyring
}

confirm_plan() {
  echo
  echo "About to install Arch Linux with the following plan:"
  echo "  Disk:       $DISK (ALL DATA WILL BE ERASED)"
  echo "  Boot mode:  $BOOT_MODE"
  if [[ "$BOOT_MODE" == "uefi" ]]; then
    echo "  EFI:        ${EFI_SIZE_MIB}MiB"
  fi
  echo "  Swap mode:  $SWAP_MODE"
  echo "  Root fs:    $ROOT_FS"
  echo "  Hostname:   $HOSTNAME"
  echo "  User:       $USERNAME ($USER_GROUPS)"
  echo "  Locale:     $LOCALE_LANG (regional: $LOCALE_REGIONAL)"
  echo "  Timezone:   $TIMEZONE"
  echo "  Keymap:     $KEYMAP_CONSOLE (X11: $KEYMAP_X11_LAYOUT/$KEYMAP_X11_MODEL)"
  echo "  Desktop:    $DESKTOP_ENV"
  echo "  Keyring:    $([[ "$UPDATE_KEYRING" == true ]] && echo "update before install" || echo "skip")"
  echo "  Virt:       $DETECTED_VIRT"
  echo

  [[ "$AUTO_CONFIRM" == true ]] && return

  read -rp "Type 'yes' to continue: " ans
  [[ "$ans" == "yes" ]] || { echo "Aborted."; exit 1; }
}

update_keyring() → Updates the Arch Linux keyring. Note: the update_keyring() function is only called later in the script.

  • pacman -Sy --noconfirm archlinux-keyring → Uses the Arch Linux package manager (pacman, from package manager, not the game, haha) to update the archlinux-keyring package.
    • Sy-S (Sync) installs packages from the repositories; -y (Refresh) updates the list of available packages.
    • --noconfirm → Doesn't ask for user confirmation, allowing the script to run the update automatically.

confirm_plan() → Most of the function consists of calls to echo, used to show the user a summary of the installation before any change is made to the system. Information such as the disk that will be formatted, boot mode, filesystem, swap, hostname, user, keyboard layout, graphical environment and other relevant settings is shown.

  • read -rp "Type 'yes' to continue: " ans → Reads the user's input and stores it in the ans (answer) variable.
  • [[ "$ans" == "yes" ]] || { echo "Aborted."; exit 1; } → Checks whether the value of ans is "yes", the only accepted answer to proceed with the installation. Otherwise, terminates the script.

deactivate_disk, partition_disk, format_partitions and mount_partitions

From here on, the functions make changes to the disk that will be used for the installation.

bash
deactivate_disk() {
  umount -R /mnt 2>/dev/null || true
  local dev
  while read -r dev; do
    [[ "$dev" == "$DISK"* ]] && swapoff "$dev" 2>/dev/null || true
  done < <(swapon --show=NAME --noheadings 2>/dev/null)
  return 0
}

partition_disk() {
  log "Partitioning $DISK"

  deactivate_disk

  wipefs -a "$DISK"

  local swap_mib
  if [[ "$SWAP_MODE" == "auto" ]]; then
    swap_mib=$(swap_size_mib)
  else
    swap_mib="$SWAP_SIZE_MIB"
  fi

  if [[ "$BOOT_MODE" == "uefi" ]]; then
    local efi_end=$(( 1 + EFI_SIZE_MIB ))
    local swap_end=$(( efi_end + swap_mib ))

    parted --script "$DISK" \
      mklabel gpt \
      mkpart ESP fat32 1MiB "${efi_end}MiB" \
      set 1 esp on \
      mkpart primary linux-swap "${efi_end}MiB" "${swap_end}MiB" \
      mkpart primary ext4 "${swap_end}MiB" 100%

    partprobe "$DISK"
    udevadm settle

    EFI_PART=$(part_suffix "$DISK" 1)
    SWAP_PART=$(part_suffix "$DISK" 2)
    ROOT_PART=$(part_suffix "$DISK" 3)
  else
    local swap_end=$(( 1 + swap_mib ))

    parted --script "$DISK" \
      mklabel msdos \
      mkpart primary linux-swap 1MiB "${swap_end}MiB" \
      mkpart primary ext4 "${swap_end}MiB" 100% \
      set 2 boot on

    partprobe "$DISK"
    udevadm settle

    SWAP_PART=$(part_suffix "$DISK" 1)
    ROOT_PART=$(part_suffix "$DISK" 2)
  fi
}

format_partitions() {
  log "Formatting partitions"
  [[ "$BOOT_MODE" == "uefi" ]] && mkfs.fat -F32 "$EFI_PART"
  mkswap "$SWAP_PART"
  swapon "$SWAP_PART"
  "mkfs.${ROOT_FS}" -F "$ROOT_PART"
}

mount_partitions() {
  log "Mounting partitions"
  mount "$ROOT_PART" /mnt
  if [[ "$BOOT_MODE" == "uefi" ]]; then
    mount --mkdir "$EFI_PART" /mnt/boot
  fi
}

deactivate_disk() → This function recursively unmounts everything inside /mnt and disables any active swap partition belonging to the disk that will be used for the installation. It exists as a cleanup step before partitioning the disk again.

It's worth noting that this function wasn't part of the first version of the script. It came up during testing, when some installation attempts failed because the disk kept being considered "busy". This happened because previous runs left pending states, such as mounted partitions or still-active swap, preventing the following operations from completing correctly.

  • umount -R /mnt → Recursively unmounts /mnt.
    • 2>/dev/null and || true → Sends the output to the "void" and keeps the script running even if the command fails.
  • while read -r dev; do ... done < <(swapon --show=NAME --noheadings 2>/dev/null) → Iterates over every device currently being used as swap on the system.
    • swapon --show=NAME --noheadings → Lists active swap partitions, showing only the device name and removing the header, e.g.: /dev/nvme0n1p2.
    • < <(...) → Called process substitution. It takes the output of the command inside the parentheses and turns it into input for the while. In practice, each line returned by swapon will be read by the loop.
    • while read -r dev → On each iteration, one line of output is stored in the dev variable.
  • [[ "$dev" == "$DISK"* ]] && swapoff "$dev" 2>/dev/null || true → Checks whether the swap partition belongs to the disk that will be formatted. The * means "anything after", so if DISK="/dev/nvme0n1", a partition like /dev/nvme0n1p2 will be considered as belonging to that disk.
    • If the condition is true, runs swapoff "$dev", disabling that swap partition before formatting.
    • 2>/dev/null discards error messages if the disabling fails.
    • || true ensures the script keeps going even if this operation fails, which matters because of the set -e enabled at the start of the script.

In summary, this part exists to ensure the disk isn't in use before being wiped and partitioned again. If a previous installation left an active swap, it's automatically disabled.

partition_disk() → Completely wipes the disk and creates the partition structure that will be used by the installation.

There's an interesting point here: I didn't clearly see the difference between partitioning a disk and formatting its partitions, but they are separate steps. Partitioning can be understood as "dividing the disk into organized parts", while formatting a partition is the step that creates a filesystem inside it, allowing it to store data.

  • deactivate_disk() → Calls the function discussed earlier to ensure the disk isn't in use, avoiding pending states from previous installations that could block the partitioning operations.
  • wipefs -a "$DISK" → Removes old filesystem signatures, such as ext4, NTFS and swap. This is necessary to prevent Linux from finding "leftovers" from old installations and misinterpreting the disk.
  • if [[ "$SWAP_MODE" == "auto" ]]; then → Checks whether the SWAP_MODE variable (defined in config.sh) is set to auto. If so, calls the swap_size_mib() function to automatically calculate the swap size; otherwise, uses the value defined in SWAP_SIZE_MIB.
  • if [[ "$BOOT_MODE" == "uefi" ]]; then → Checks whether the chosen boot mode is UEFI. If so, creates the partitions following the scheme required for that boot type.
  • parted --script "$DISK"parted is the program used to manipulate the disk's partition table. The --script parameter makes it run the commands automatically, without entering interactive mode or asking for confirmations. The following commands are instructions passed to parted.
    • mklabel gpt → Creates a new partition table in the GPT (GUID Partition Table) format. This is the standard used in modern UEFI installations.
    • mkpart ESP fat32 1MiB "${efi_end}MiB" → Creates a partition called ESP (EFI System Partition), reserved for storing UEFI boot files. It uses the FAT32 format and has its start and end defined by the given values.
    • set 1 esp on → Marks the first partition as an ESP. This flag tells the UEFI firmware that partition contains boot files.
    • mkpart primary linux-swap "${efi_end}MiB" "${swap_end}MiB" → Creates the partition intended for swap. At this point, it just defines the disk area; the swap itself is actually created later with mkswap.
    • mkpart primary ext4 "${swap_end}MiB" 100% → Creates the system's root partition, using all the remaining disk space. ext4 indicates the expected partition type, but the filesystem is created later with mkfs.ext4.
  • partprobe "$DISK" → The partprobe program tells the kernel that the disk's partition table has changed. Think of it as a "refresh" so the system recognizes the new structure without needing a reboot.
  • udevadm settle → Waits for udev to finish creating the devices corresponding to the new partitions. This prevents the script from trying to access a partition before it exists in /dev (race condition).
  • else → Runs similar logic, but creating the partitions following the traditional BIOS/MBR scheme, which uses an msdos partition table instead of GPT and doesn't need a separate EFI partition.

format_partitions() → Since the previous function already created the partitions, the next step now is to format them, i.e., create the filesystems that will be used by the operating system.

  • [[ "$BOOT_MODE" == "uefi" ]] && mkfs.fat -F32 "$EFI_PART" → If BOOT_MODE is UEFI, formats the EFI_PART partition using the FAT32 filesystem.
  • mkswap "$SWAP_PART" and swapon "$SWAP_PART" → The first command sets up the SWAP_PART partition as a swap area; the second activates that area so it can be used by the system.
  • "mkfs.${ROOT_FS}" -F "$ROOT_PART" → Formats the ROOT_PART partition using the filesystem defined in config.sh. Currently, that value is ext4, but the command is built dynamically from the ROOT_FS variable, allowing the filesystem to be changed without modifying the script.

mount_partitions()mount is the command responsible for making a filesystem accessible to Linux through a directory. During the Arch installation, by convention /mnt is used as the mount point for the new installation. So everything installed into /mnt will actually be written to the newly created root partition.

  • mount "$ROOT_PART" /mnt → Mounts the root partition at /mnt.
  • mount --mkdir "$EFI_PART" /mnt/boot → Runs only on UEFI installations. Creates the /mnt/boot directory if it doesn't already exist and mounts the ESP partition there, allowing boot files to be written to the correct location.

pacstrap_system and genfstab_system

Now that everything has been prepared, it's time to install Arch Linux!

bash
pacstrap_system() {
  log "Installing base system (pacstrap)"

  local microcode virt_pkgs de_pkgs
  microcode=$(detect_microcode)
  virt_pkgs=$(virt_packages_for "$DETECTED_VIRT")
  de_pkgs=$(de_packages_for)

  local pkgs=(base linux linux-firmware networkmanager grub sudo vim nano git base-devel)
  [[ "$BOOT_MODE" == "uefi" ]] && pkgs+=(efibootmgr)
  [[ -n "$microcode" ]] && pkgs+=("$microcode")
  [[ -n "$virt_pkgs" ]] && pkgs+=($virt_pkgs)
  [[ -n "$de_pkgs" ]] && pkgs+=($de_pkgs)

  pacstrap -K /mnt "${pkgs[@]}"
}

genfstab_system() {
  log "Generating fstab"
  genfstab -U /mnt >> /mnt/etc/fstab
}

pacstrap answers the following question:

How do you fit a whole Arch Linux inside this partition?

It downloads packages from the Arch Linux repositories and installs them into the given directory (by convention, during installation, /mnt is used). The reason for not using pacman directly is that it installs packages into the currently running system, which in this case is the Live ISO. pacstrap, on the other hand, installs packages into a different directory. It's worth noting that pacstrap is a wrapper around pacman: it uses pacman internally to download and install packages, but adds extra logic to allow installing a complete system into a directory other than the running system.

pacstrap_system() → This function builds an array with all the packages needed for the installation and, at the end, uses pacstrap to install that set into /mnt, which at this point represents the root partition (root) of the system being built.

  • microcode=$(detect_microcode), virt_pkgs=$(virt_packages_for "$DETECTED_VIRT") and de_pkgs=$(de_packages_for) → Call the functions created earlier to find out which additional packages should be installed and store their returns in the microcode, virt_pkgs and de_pkgs variables.
  • local pkgs=(base linux linux-firmware networkmanager grub sudo vim nano git base-devel) → Initializes the pkgs array with the packages that will always be installed, regardless of installation type. They represent the base of a working Arch Linux system.
  • [[ "$BOOT_MODE" == "uefi" ]] && pkgs+=(efibootmgr) → If the boot mode is UEFI, adds the efibootmgr package to the pkgs array.
  • [[ -n "$..." ]] && pkgs+=("$...") → The same logic is used to add the microcode, virtualization and desktop environment packages to the array, if they exist.
  • pacstrap -K /mnt "${pkgs[@]}" → Finally, installs the Arch Linux system and all the packages gathered in the pkgs array into /mnt, i.e., into the newly created root partition.

genfstab_system() → This function automatically generates the fstab file (File System Table), responsible for telling Linux which partitions should be mounted at system startup and in which directories they should appear.

  • genfstab -U /mnt >> /mnt/etc/fstabgenfstab scans everything mounted inside /mnt and generates the corresponding entries for the fstab file. The -U flag makes partitions be identified by their UUID (Universally Unique Identifier), a unique identifier for each filesystem, instead of paths like /dev/sda1, which can change. Finally, >> redirects that output to the /mnt/etc/fstab file, which will be used by the system after installation.

prepare_chroot, run_chroot and cleanup_chroot_files

Now, Arch is installed on the machine, and the goal is to configure the system before rebooting it. This is where arch-chroot comes in, a Linux mechanism that temporarily changes which directory is considered the root / of a process. This will be useful for making all the settings we need.

bash
prepare_chroot() {
  log "Preparing chroot setup"
  cp "$SCRIPT_DIR/config.sh" /mnt/root/config.sh
  cp "$SCRIPT_DIR/chroot-setup.sh" /mnt/root/chroot-setup.sh
  chmod +x /mnt/root/chroot-setup.sh

  install -m 600 /dev/null /mnt/root/.archinstall_secrets
  cat > /mnt/root/.archinstall_secrets <<EOF
ROOT_PASSWORD='${ROOT_PASSWORD}'
USER_PASSWORD='${USER_PASSWORD}'
DETECTED_VIRT='${DETECTED_VIRT}'
BOOT_MODE='${BOOT_MODE}'
DISK='${DISK}'
DESKTOP_ENV='${DESKTOP_ENV}'
EOF
}

run_chroot() {
  log "Entering chroot for system configuration"
  arch-chroot /mnt /root/chroot-setup.sh
}

cleanup_chroot_files() {
  rm -f /mnt/root/.archinstall_secrets /mnt/root/config.sh /mnt/root/chroot-setup.sh
}

prepare_chroot() → Prepares the chroot environment, copying the files needed to continue configuring the system.

  • cp "$SCRIPT_DIR/config.sh" /mnt/root/config.sh and cp "$SCRIPT_DIR/chroot-setup.sh" /mnt/root/chroot-setup.sh → Copies the config.sh and chroot-setup.sh files to /mnt/root/, allowing them to be run after entering the chroot environment.
  • install -m 600 /dev/null /mnt/root/.archinstall_secrets → Creates the .archinstall_secrets file with restricted permissions (600), allowing read and write access only to the root user.
  • cat > /mnt/root/.archinstall_secrets <<EOF ... EOF → Writes to this file information needed for the next installation step, such as passwords, boot mode, installation type and other variables that will be used by chroot-setup.sh.

run_chroot() → Enters the chroot environment and runs the script responsible for configuring the newly installed system.

  • arch-chroot /mnt /root/chroot-setup.sh → Enters the chroot environment, making /mnt be treated as the root (/) of the filesystem, and runs the chroot-setup.sh script.

cleanup_chroot_files() → Removes from the system the files used only during installation, including .archinstall_secrets, which contains sensitive information such as passwords.

  • rm -f /mnt/root/.archinstall_secrets /mnt/root/config.sh /mnt/root/chroot-setup.sh → Removes the temporary files used during installation. The -f (force) flag avoids errors if any of them no longer exist.

main

The main function works as an orchestrator. It practically contains no logic of its own; its responsibility is just to call the functions in the correct order, organizing the whole installation flow.

This pattern makes the script more readable and easier to maintain, since each function has a single responsibility, while main just describes the sequence of steps.

bash
main() {
  check_root
  check_internet

  prompt_install_type
  prompt_boot_mode

  detect_disk
  DETECTED_VIRT=$(detect_virt)

  prompt_passwords
  prompt_update_keyring
  confirm_plan

  partition_disk
  format_partitions
  mount_partitions
  [[ "$UPDATE_KEYRING" == true ]] && update_keyring
  pacstrap_system
  genfstab_system

  prepare_chroot
  trap cleanup_chroot_files EXIT
  run_chroot

  log "Installation finished. Unmounting."
  umount -R /mnt
  swapoff "$SWAP_PART" || true

  echo
  echo "All done. Run 'reboot' when ready."
}

main "$@"

Its flow can be split into four major phases:

  1. Validation and information gathering → Checks permissions, internet connection, gathers information about the system and asks the user for the necessary data.
  2. Disk preparation → Partitions, formats and mounts the partitions that will hold the new system.
  3. Base system installation → Installs Arch Linux with pacstrap, generates the fstab and prepares the environment to enter chroot.
  4. Final configuration → Runs the chroot-setup.sh script, unmounts the partitions, disables swap and finishes the installation.

Some details deserve attention:

  • [[ "$UPDATE_KEYRING" == true ]] && update_keyring → Updates the archlinux-keyring only if the user requested that step.

  • trap cleanup_chroot_files EXIT → Registers the cleanup_chroot_files function to run automatically when the script ends, regardless of the reason (normal completion or error). This ensures that temporary files, such as .archinstall_secrets, don't remain on the system after installation.

  • main "$@" → Starts running the script by calling the main function. "$@" represents all the arguments passed on the command line and forwards them to the function, although this script doesn't use them.

Is it finally over?


This was definitely a huge post, full of details and tiring both to write and to read. Even so, I hope it showed that installing an operating system involves a lot more than it seems; also, that when we break the process down into small steps, each one starts to make a lot of sense.

However, it's still not over. In the next post, we'll see what happens during the system configuration inside chroot, before the installation is finished. Although it's part of the same process, this step deserves its own article, since it has quite different responsibilities from archinstall.sh. Fortunately, it will be considerably smaller and less dense than this one.