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.
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.shandchroot-setup.shfiles (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:
#!/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()anderr()→ helper functions to standardize messages.err()sends the output tostderr, whilelog()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/archinstallcd→ Enters that directory.pwd→ Gets the absolute path of the current directory. Sincecdproduces no output,pwdis needed so that$(...)captures that path and assigns it to theSCRIPT_DIRvariable.$(...)→ 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 tostderr(the standard stream intended for a process's error messages).>→ redirects the output.&→ indicates that the following number represents a file descriptor.2→ is thestderrfile 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.
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. Therootuser always hasUID 0.[[ $EUID -eq 0 ]]→ Literally means "is the value of$EUIDequal 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.
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 variablesdiskandnum.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 apbefore 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-nflag means non-empty. Soreturnwill only run if the expression on the left is true, i.e., ifDISKalready has a value. In this case, it means the variable was already defined (inconfig.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/sdaand/dev/nvme0n1, after runningmapfile, the array will bedisks[0]="/dev/sda"anddisks[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:-dshows only disks (without partitions);-nomits the header;-pshows the full device path;-odefines the columns to display (in this case,NAMEandTYPE). The output will be something like/dev/sda disk,/dev/nvme0n1 diskand/dev/sda1 part.awk '$2=="disk"{print $1}'→ When the second column isdisk, prints the first column. In practice, this filters thelsblkoutput, keeping only disks and discarding partitions, CD-ROMs and other devices.mapfile→ Reads the lines produced by the previous command and builds thedisksarray.
- Example: if the input is
if,elifandelse→ After filling thedisksarray, the script checks how many disks were found.- If the
disksarray 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.
- If the
read -rp "Select disk number: " choice→readreads user input;-rreads it literally, without interpreting backslashes (\);-pshows a prompt before waiting for input (in this case,"Select disk number: ");choiceis the variable where the answer will be stored.DISK="${disks[$((choice - 1))]}"→ Assigns the disk chosen by the user to theDISKvariable. The- 1exists 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.
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 theawkprogram is used to read a specific line and apply an action. In this case, it reads the/proc/meminfofile, looks for theMemTotalline, which contains the machine's total RAM, divides that value by1024(sinceMemTotalis given in kB) and returns the result in MiB, assigning it to the local variableram_mib.if,elifandelse→ Check the value ofram_miband, 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 inswap_size_mib(),awkis used to read a system file. In this case, it looks for the line containingvendor_idin/proc/cpuinfo, extracts the CPU vendor and assigns the result to the local variablevendor.-F:→ Sets:as the column separator (Field Separator).exit→ Terminatesawkafter finding the first occurrence ofvendor_id, since it only needs to read once.| tr -d ' '→ Thetrprogram removes all spaces from the string returned byawk.
case "$vendor" in ...→ Checks the value ofvendor. If it'sGenuineIntel, returns the Intel microcode package; if it'sAuthenticAMD, 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 thesystemd-detect-virtprogram, which tries to identify whether the system is running in a virtualized environment, and stores the output in theoutvariable.2>/dev/null→ Redirectsstderrto/dev/null, discarding error messages.|| true→ Ifsystemd-detect-virtreturns an error (a code other than0), runstrue, causing the script to continue normally instead of stopping. > Note: during testing,systemd-detect-virtidentified 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:
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():
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→$1represents the first argument passed to the function. In this case, it receives the value ofDETECTED_VIRT, which contains the detected virtualization type.oracle)→ If VirtualBox is detected, returns thevirtualbox-guest-utilspackage.kvm|qemu)→ If KVM or QEMU is detected, returns theqemu-guest-agentpackage.*)→ 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 theDESKTOP_ENVvariable, 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 ofDESKTOP_ENVisn'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():
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 thearchlinux-keyringpackage.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 theans(answer) variable.[[ "$ans" == "yes" ]] || { echo "Aborted."; exit 1; }→ Checks whether the value ofansis "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.
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/nulland|| 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 thewhile. In practice, each line returned byswaponwill be read by the loop.while read -r dev→ On each iteration, one line of output is stored in thedevvariable.
[[ "$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 ifDISK="/dev/nvme0n1", a partition like/dev/nvme0n1p2will be considered as belonging to that disk.- If the condition is true, runs
swapoff "$dev", disabling that swap partition before formatting. 2>/dev/nulldiscards error messages if the disabling fails.|| trueensures the script keeps going even if this operation fails, which matters because of theset -eenabled at the start of the script.
- If the condition is true, runs
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 theSWAP_MODEvariable (defined inconfig.sh) is set toauto. If so, calls theswap_size_mib()function to automatically calculate the swap size; otherwise, uses the value defined inSWAP_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"→partedis the program used to manipulate the disk's partition table. The--scriptparameter makes it run the commands automatically, without entering interactive mode or asking for confirmations. The following commands are instructions passed toparted.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 calledESP(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 withmkswap.mkpart primary ext4 "${swap_end}MiB" 100%→ Creates the system's root partition, using all the remaining disk space.ext4indicates the expected partition type, but the filesystem is created later withmkfs.ext4.
partprobe "$DISK"→ Thepartprobeprogram 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 forudevto 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 anmsdospartition 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"→ IfBOOT_MODEisUEFI, formats theEFI_PARTpartition using theFAT32filesystem.mkswap "$SWAP_PART"andswapon "$SWAP_PART"→ The first command sets up theSWAP_PARTpartition as a swap area; the second activates that area so it can be used by the system."mkfs.${ROOT_FS}" -F "$ROOT_PART"→ Formats theROOT_PARTpartition using the filesystem defined inconfig.sh. Currently, that value isext4, but the command is built dynamically from theROOT_FSvariable, 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 onUEFIinstallations. Creates the/mnt/bootdirectory if it doesn't already exist and mounts theESPpartition 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!
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")andde_pkgs=$(de_packages_for)→ Call the functions created earlier to find out which additional packages should be installed and store their returns in themicrocode,virt_pkgsandde_pkgsvariables.local pkgs=(base linux linux-firmware networkmanager grub sudo vim nano git base-devel)→ Initializes thepkgsarray 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 isUEFI, adds theefibootmgrpackage to thepkgsarray.[[ -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 thepkgsarray 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/fstab→genfstabscans everything mounted inside/mntand generates the corresponding entries for thefstabfile. The-Uflag makes partitions be identified by theirUUID(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/fstabfile, 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.
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.shandcp "$SCRIPT_DIR/chroot-setup.sh" /mnt/root/chroot-setup.sh→ Copies theconfig.shandchroot-setup.shfiles to/mnt/root/, allowing them to be run after entering thechrootenvironment.install -m 600 /dev/null /mnt/root/.archinstall_secrets→ Creates the.archinstall_secretsfile with restricted permissions (600), allowing read and write access only to therootuser.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 bychroot-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 thechrootenvironment, making/mntbe treated as the root (/) of the filesystem, and runs thechroot-setup.shscript.
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.
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:
- Validation and information gathering → Checks permissions, internet connection, gathers information about the system and asks the user for the necessary data.
- Disk preparation → Partitions, formats and mounts the partitions that will hold the new system.
- Base system installation → Installs Arch Linux with
pacstrap, generates thefstaband prepares the environment to enterchroot. - Final configuration → Runs the
chroot-setup.shscript, unmounts the partitions, disables swap and finishes the installation.
Some details deserve attention:
[[ "$UPDATE_KEYRING" == true ]] && update_keyring→ Updates thearchlinux-keyringonly if the user requested that step.trap cleanup_chroot_files EXIT→ Registers thecleanup_chroot_filesfunction 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 themainfunction."$@"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.