Chapter 2: Linux & Shell
Master the Linux command line for managing GPU servers, automating ML workflows, and maintaining production infrastructure.
- Topics
- 8
- Demo
- 1
- Videos
- 3
- Min read
- 63
Chapter Overview
Linux is the operating system of AI. Nearly every GPU server, cloud instance, HPC cluster, and container orchestration platform runs Linux. Your ability to navigate the filesystem, manage processes, write automation scripts, and configure services directly determines how productive you are as an ML engineer. Engineers who are comfortable on the command line ship experiments faster, debug problems more quickly, and build more reliable infrastructure.
This chapter covers the essential Linux skills for AI engineering, organized in order of daily usage. You will start with the filesystem — understanding where things live and how permissions work. Then move to the commands you will use hundreds of times a day for file manipulation, searching, and piping. Process management teaches you how to keep training jobs alive and monitor GPU resources. Users and permissions set up shared infrastructure for team collaboration. Shell scripting automates everything you do manually. Package management prevents dependency hell. Cron and systemd handle scheduling and service management. And environment variables keep your configuration secure and portable.
Each topic is taught through the lens of real ML workflows — not abstract system administration. You will learn to organize training projects, analyze logs with command-line tools, manage multi-user GPU servers, write experiment launcher scripts, and deploy model servers as auto-restarting services. These are the skills that distinguish an ML engineer who gets things done from one who spends hours fighting with infrastructure.
Chapter Roadmap
Click any topic to jump in
Linux File System
FHS hierarchy, inodes, permission triplets, symlinks — the on-disk model every other Linux concept builds on.
Essential Commands
ls, find, grep, pipes, redirection — the core verbs of getting work done at the shell.
Process Management
PIDs, signals, nohup, tmux, nvidia-smi — how to launch, inspect, and recover long-running training jobs.
From files to running programs
Users & Permissions
Users, groups, sudo, umask, ownership — the security model that keeps multi-tenant GPU boxes sane.
Shell Scripting
Shebangs, variables, conditionals, loops, error handling — gluing tools into reproducible pipelines.
Package Management
apt vs pip vs conda, virtual envs, requirements files — controlling exactly which versions actually run.
Cron & Systemd
Scheduled jobs and managed services — how to make scripts run on a schedule and survive reboots.
Environment Variables
export, .env, CUDA_VISIBLE_DEVICES, secrets management — the configuration channel between OS, shell, and apps.
Topics
8 topics in this chapter
You SSH into a new GPU server and need answers fast. Where is CUDA installed? Which disk has room for a 500 GB dataset? Why does ssh refuse the key you just copied over? Every one of those questions is answered by the same structure: the Linux file system, and the permissions attached to every file in it.
Linux has no drive letters. Everything, including extra disks, network shares, devices and even running processes, appears somewhere in a single tree that starts at /. The Filesystem Hierarchy Standard gives each top-level directory a job: /etc for configuration, /home for users, /var for logs and other changing data, /usr for installed software, /tmp for scratch files and /dev for devices such as GPUs. Learn that map once and you can find your way around any distribution.
Each file also carries an owner, a group and three sets of read, write and execute bits, which decide who can do what. This topic covers the hierarchy, the permission model and chmod and chown, symbolic links for keeping big datasets out of your project tree, and the tools for finding what is filling a disk.
Definition
The Linux file system is a single hierarchical namespace rooted at /, into which every storage device and pseudo-filesystem is mounted, with directory roles defined by the Filesystem Hierarchy Standard. Each file is an inode with an owner, a group and a permission mode of read, write and execute bits for owner, group and others.
In this topic
- 1Root Hierarchy
- 2Permission Triplet
- 3chmod
- 4chown
- 5Symbolic Links
- 6Disk Space Management
Root Hierarchy
The entire Linux filesystem is a single tree rooted at /. There are no drive letters like Windows. Every directory serves a specific purpose: /home stores user data and personal projects, /tmp provides temporary storage cleared on reboot, /var holds variable data like logs and caches, /etc contains system-wide configuration files, /usr houses installed programs and libraries, /opt stores optional software like CUDA, /proc exposes process information as virtual files, and /dev represents hardware devices including GPUs.
Knowing this layout means you can find things on any Linux machine. CUDA toolkit? Check /usr/local/cuda or /opt/cuda. System logs? /var/log. GPU device files? /dev/nvidia*. This consistency across all Linux distributions is one of the platform's strengths.
The Filesystem Hierarchy Standard (FHS) is a tree where every file lives at exactly one canonical path. Lookup is where is the depth — the kernel walks / → etc → nginx → nginx.conf doing one inode lookup per component. The dentry cache (dcache) hashes recently-resolved paths, dropping repeat lookups to .
You SSH into a new GPU server for the first time. You need to verify CUDA is installed, check available GPU devices, find how much disk space is free, and locate your home directory. What commands do you run?
Permission Triplet
Every file in Linux has three sets of permissions — for the owner, the group, and others. Each set can include read (r=4), write (w=2), and execute (x=1). The numeric representation sums these values: 755 means owner has rwx (7), group has r-x (5), others have r-x (5).
Critical permission values for ML work: 600 for SSH keys (owner read-write only — SSH refuses keys with looser permissions), 755 for scripts you want to execute, 775 for shared project directories (group-writable), and 644 for regular files (owner read-write, everyone else read-only).
The rwx mode is encoded in 9 bits (3 sets of 3), giving possible permission combinations. With the setuid/setgid/sticky bits added, that's 12 bits, hence the 4-digit octal mode (e.g., 0755). Each octal digit packs r=4, w=2, x=1 — so chmod 755 mentally decomposes to rwxr-xr-x in .
You get 'Permission denied' when running ssh -i ~/.ssh/gpu_key ubuntu@server even though the key file exists. ls -la ~/.ssh/gpu_key shows -rw-rw-r-- 1 alice alice. What's wrong?
chmod
The chmod command changes file permissions. It accepts either symbolic notation (chmod +x script.sh adds execute for all, chmod g+w file adds group write) or numeric notation (chmod 755 script.sh sets owner=rwx, group=rx, others=rx).
The -R flag applies permissions recursively to directories: chmod -R 775 /data/datasets/ sets all files and subdirectories to be group-writable. Be careful with recursive chmod on directories containing a mix of files and executables — you might accidentally make data files executable or remove execute permission from scripts.
Changing a mode is one metadata update per file, independent of file size, so a recursive chmod costs one system call per file in the tree. On a local disk that is fast, but on a network filesystem every change is a round trip to the server, so a dataset of millions of small files can take a long time. Avoid blanket recursion: chmod -R a+X adds execute only to directories, and find with -type f or -type d applies separate modes to files and directories.
chown
The chown command changes file ownership, setting both the user and group owner. The syntax is chown user:group file. This is critical when files created by root need to be accessed by service accounts, or when a new team member needs to take ownership of a project directory.
Common scenario: you install a Python package with sudo, and the resulting files are owned by root. Your training script can't write to those directories. Fix: sudo chown -R $USER:$USER ~/miniconda3/ transfers ownership back to you.
Ownership is two numbers stored in the inode, a user ID and a group ID, not names. Names come from /etc/passwd and /etc/group at display time. That is why files copied from another machine, or written by a container running as UID 1000, can show up owned by a stranger or a bare number: the IDs match a different user, or none, on this host. Matching UIDs across machines, or mapping them, is what makes shared storage behave.
Symbolic Links
Symbolic links (symlinks) are shortcuts that point to another file or directory. Created with ln -s target link_name, they appear as aliases in the filesystem. A common ML use case: keep large datasets on a separate high-capacity volume while maintaining a clean project structure with ln -s /mnt/data-volume/imagenet ~/project/data/imagenet.
Symlinks are also used extensively for managing CUDA versions (/usr/local/cuda is often a symlink to /usr/local/cuda-12.2) and for Python virtual environments. The -s flag creates a symbolic (soft) link; without it, ln creates a hard link, which has different semantics.
A symlink is a tiny inode (typically <60 bytes) holding a path string. Resolution adds 1 syscall per symlink in the path. The kernel caps symlink resolution at 40 hops (MAXSYMLINKS) to prevent infinite loops — exceed it and you get ELOOP. Hard links bypass this entirely: they're just additional dentries pointing to the same inode, at all access times.
Disk Space Management
GPU servers frequently run out of disk space from accumulated model checkpoints, training logs, and dataset caches. df -h shows filesystem-level usage in human-readable format. du -sh * shows the size of each item in the current directory. ncdu provides an interactive browser where you can navigate directories and see sizes in real-time — press 'd' to delete files directly.
A common cleanup workflow: find checkpoints older than a week (find ./checkpoints -name '*.pt' -mtime +7), identify the largest files (du -sh * | sort -rh | head), and remove all but the best N checkpoints. Automate this with a cron job to prevent disk-full emergencies during overnight training runs.
du walks every inode in time and returns block-level usage; df reads filesystem superblock metadata in . They disagree when files are deleted but still held open by a process: the kernel keeps the inode, df reports the bytes as used, du can't see them. lsof | grep deleted finds the culprit. ML training logs are the canonical example.
Theory Exercise
Problem:
You join a team where three ML engineers share a GPU server. Each engineer has their own home directory, but they need shared access to datasets in /data/ and model checkpoints in /models/. Currently, Alice's training scripts can't read Bob's checkpoints, and Carol's datasets are only accessible with sudo. Design a permission scheme that allows all three engineers to read and write shared resources while keeping home directories private.
Hints:
- Think about creating a shared group and using the setgid bit on shared directories
- Consider what umask setting would be appropriate for the team
- Remember that new files inherit the creating user's primary group unless setgid is set
Related Problems on PixelBank
A training run died overnight. To find out why, you need the newest log, the line where things went wrong, and the checkpoint written just before. Clicking through a file browser over SSH is not an option. A handful of commands, combined well, answer all three questions in seconds.
The core set is small. ls lists files, with flags for sizes, hidden files and sorting. cp, mv and rm copy, move and delete, and rm has no undo. find locates files by name, size, age or type and can act on every match. grep searches inside files for a pattern. The real power comes from composition: a pipe sends one program's output into the next program's input, and redirection sends output to files. Small tools that each do one job become a custom tool for the problem at hand, which is the original Unix design idea.
Faster modern replacements exist, such as ripgrep and fd, and they are worth installing on your own machines. But the classic tools are on every server you will ever log into, so learn those first.
Definition
The essential commands are the standard Unix tools for listing (ls), locating (find), searching (grep), copying and moving (cp, mv) and deleting (rm) files, combined through pipes, which connect one process's standard output to the next one's standard input, and redirection, which connects a process's input or output streams to files.
In this topic
- 1ls Flags
- 2find
- 3grep
- 4Piping (|)
- 5Redirection
- 6Modern Alternatives
ls Flags
The ls command lists directory contents, but its flags unlock its real power. -l provides long format showing permissions, owner, size, and modification date. -a shows hidden files (those starting with a dot, like .env and .bashrc). -h converts sizes to human-readable format (KB, MB, GB). -t sorts by modification time (newest first). -S sorts by file size (largest first).
Combine them: ls -lahS shows everything including hidden files, with human-readable sizes, sorted by size. This is the first command to run when investigating disk usage in a directory full of model checkpoints.
ls reads each directory inode with getdents64(), then stat()s each entry — that's syscalls for files. On a directory with entries, ls takes seconds; ls -f (no sort, no stat) takes milliseconds. The lesson: stat is expensive on large directories, especially over NFS where each one is an RPC.
find
The find command locates files by matching criteria. Search by name (find . -name '*.py'), by size (find . -size +100M for files over 100MB), by modification time (find . -mtime -7 for files changed in the last 7 days), or by type (find . -type d for directories only).
The -exec flag runs a command on each result: find . -name '*.pyc' -delete removes all compiled Python files. For ML projects, find is essential for locating checkpoints, cleaning up cache files, and auditing large files that might be consuming disk space.
The cost that matters is process creation, not the directory walk. find with -exec and a terminating semicolon starts one process per match. On 20,000 empty files that took 16.1 seconds on a test machine, about 0.8 ms per file. Ending -exec with a plus sign instead packs as many paths as fit into each command line, limited by ARG_MAX (2 MB here), and the same job took 23 ms, about 700 times faster. Built-in actions such as -delete and -printf avoid starting processes at all.
After a week of training experiments, you need to find all checkpoint files larger than 500MB that were created in the last 3 days, and list them sorted by size. Write the command.
grep
grep searches file contents for patterns. The most useful flags: -r (recursive search through directories), -n (show line numbers), -i (case insensitive), -l (show only filenames that match, not the matching lines), and -c (count matches). Extended regex with -E enables patterns like grep -E 'error|exception|fail'.
For ML work, grep is indispensable for searching training logs (grep 'OOM' logs/*.log), finding configuration values (grep -r 'learning_rate' configs/), and locating code patterns (grep -rn 'import torch' src/).
For one large file already in memory, grep and ripgrep run at similar speed. Searching a 400 MB cached file for a literal string took 99 ms with GNU grep and 103 ms with rg on a test machine, around 4 GB per second each, because both use fast literal scanning. ripgrep's large wins come on directory trees: it searches files in parallel and skips what .gitignore excludes, such as virtual environments and data folders, so it reads far fewer bytes. Benchmark your own workload before assuming a speedup.
Piping (|)
The pipe operator (|) connects the output (stdout) of one command to the input (stdin) of the next. This enables powerful command chains: nvidia-smi | grep 'MiB' extracts memory usage lines from GPU status. cat train.log | grep 'loss' | tail -20 shows the last 20 lines containing 'loss'.
Pipes chain simple tools into powerful workflows. Each Unix tool does one thing well — ls lists files, grep filters text, sort orders lines, head/tail selects portions — and pipes combine them. This composability is the philosophical foundation of Unix and the reason the command line remains essential.
A pipe creates a kernel-managed bounded buffer (default 64 KB on Linux). Producers block on write() when the buffer fills, consumers block on read() when it empties — no userland coordination needed. Throughput is bounded by the slowest stage, exactly like a CPU pipeline. The model is composable because each stage runs as an independent process scheduled by the kernel.
Redirection
Redirection sends command output to files instead of the terminal. > writes to a file (overwriting existing content). >> appends to a file. 2>&1 redirects stderr to the same destination as stdout. 2>/dev/null discards error messages.
The essential ML pattern: python train.py > train.log 2>&1 & runs training in the background, captures all output (both normal and error) to a log file. Without 2>&1, error messages (CUDA errors, OOM messages) would go to the terminal and be lost. The trailing & runs the process in the background.
The operator decides how the file is opened. A single greater-than sign opens it with truncation, so the old contents are gone before the command even starts, which is why sort data.txt into data.txt leaves an empty file. Two of them open it in append mode, where each write goes to the current end of the file, so several processes appending log lines do not overwrite one another. Order matters too: redirect stdout to a file first, then 2>&1, so stderr follows it to the file.
Modern Alternatives
A newer generation of tools improves on the classics. fd is a simpler find with sensible defaults. ripgrep (rg) searches recursively, in parallel, and skips files listed in .gitignore, which makes it much faster than grep -r on large repositories. bat is cat with syntax highlighting and line numbers, fzf adds fuzzy, interactive selection to any list, and eza is a modern ls. ncdu browses disk usage. They are worth installing on machines you use daily. Scripts that run on servers should still use the POSIX tools, because those are always present while the new ones often are not.
Most of the speed comes from doing less work rather than doing the same work faster. Skipping ignored directories can remove most of the bytes in an ML repository, where .venv, data and checkpoint folders dwarf the source code. Using every core helps when there are many files. For a single file both tools hit similar limits, so a claim like 'ten times faster' depends entirely on the workload.
A shared GPU server raises questions you cannot answer by looking at your own terminal. Which process is holding 40 GB on GPU 2? Will your six-hour training run survive when your laptop sleeps and SSH drops? How do you stop a job so that it saves a checkpoint instead of losing an hour of work? All of these come down to managing processes.
Every running program is a process with an ID, a parent, an owner and a state. ps, top and htop show them, and nvidia-smi shows which processes hold GPU memory. Processes started from an SSH session are tied to it: when the session ends, the shell's jobs receive SIGHUP and usually exit. nohup, tmux and screen keep work alive after you disconnect, and tmux also lets you reattach and see the live output.
Signals are how you talk to a running process. SIGTERM asks it to shut down cleanly, which a training script can catch to save a checkpoint. SIGKILL ends it immediately with no cleanup. SIGSTOP and SIGCONT pause and resume it. This topic covers all of these tools, building on the commands from the previous topic.
Definition
A process is a running instance of a program, identified by a process ID (PID) and holding its own memory, open files and credentials. Process management means observing processes (ps, top, nvidia-smi), controlling their lifetime across sessions (job control, nohup, tmux) and communicating with them through signals such as SIGTERM, SIGKILL, SIGSTOP and SIGCONT.
In this topic
- 1PID
- 2nvidia-smi
- 3nohup
- 4tmux
- 5Background Jobs
- 6Signals
PID
Every running process has a unique Process ID (PID) assigned by the kernel. Use ps aux | grep python to find training processes and their PIDs. kill <PID> sends SIGTERM for a graceful shutdown. pgrep -f train.py finds PIDs by command name without the grep gymnastics.
PIDs are sequential and wrap around after reaching the maximum (typically 32768 or 4194304 on modern systems). The init process always has PID 1. Child processes inherit their parent's PID as their PPID (Parent PID), forming a process tree you can view with pstree.
PIDs are allocated upward until they reach /proc/sys/kernel/pid_max, then wrap around and reuse free numbers. The kernel default is 32,768, but systemd raises it to 4,194,304 on modern distributions, as on the Ubuntu test machine. Reuse is why a PID written to a file is not a reliable handle: after the process exits, the same number can later belong to something else, and killing it blindly hits the wrong process. Supervisors such as systemd track processes by cgroup instead.
nvidia-smi
nvidia-smi is the essential GPU monitoring tool. It shows GPU utilization percentage, memory usage (used/total), temperature, power consumption, and all processes running on each GPU. Key usage patterns: nvidia-smi -l 1 refreshes every second for real-time monitoring. nvidia-smi --query-gpu=gpu_name,utilization.gpu,memory.used --format=csv produces machine-parseable output for scripts.
The --query-compute-apps flag shows per-process GPU memory usage, helping you identify which training jobs or zombie processes are consuming GPU memory. When a training job crashes but doesn't release GPU memory, nvidia-smi reveals the leaked process so you can kill it.
The driver frees a process's GPU memory when the last process holding that CUDA context exits, so 'memory used, nothing running' almost always means something is still running that you cannot see. The query interface is cheap: nvidia-smi --query-gpu with -l 1 is fine for a terminal. For continuous monitoring across many GPUs, use DCGM and its Prometheus exporter, which sample metrics without launching a new process on every reading.
nvidia-smi shows 15GB of GPU memory used on GPU 0, but no processes are listed under 'Processes'. What happened and how do you reclaim the memory?
nohup
nohup (no hangup) prevents a process from being killed when your SSH session disconnects. The command nohup python train.py & runs training in the background, immune to the SIGHUP signal. Output is redirected to nohup.out by default.
nohup is simple but limited — you can't reattach to see live output, you can't scroll back through output history, and managing multiple nohup processes is cumbersome. For anything beyond a quick one-off job, use tmux instead.
nohup ignores SIGHUP (sent when the terminal closes) and redirects stdout/stderr to nohup.out. Without it, every backgrounded process dies on logout. Modern alternative: systemd-run --user --scope puts the process in its own cgroup, surviving logout AND giving you systemctl status for free.
tmux
tmux (terminal multiplexer) creates persistent terminal sessions that survive SSH disconnections. You create a named session (tmux new -s training), start your training job, detach (Ctrl+B then D), close your SSH connection, and the training continues. Later, you reattach (tmux attach -t training) from any SSH connection and see your training exactly where you left it.
tmux also supports splitting the terminal into panes (Ctrl+B then % for vertical split, " for horizontal) and multiple windows (Ctrl+B then C for new window). A typical setup: left pane runs training, right pane shows nvidia-smi -l 2 for real-time GPU monitoring.
tmux survives disconnection because of who owns the processes. Your shells inside tmux are children of the tmux server, not of the SSH session, so when SSH dies only the tmux client exits and nothing sends the hangup signal to your job. The server keeps a scrollback buffer per pane, by default 2,000 lines of history, so you can reattach and scroll back through what happened. Raise history-limit for chatty training logs, or better, also write the log to a file.
Background Jobs
Appending & to a command runs it in the background, returning control of the terminal immediately. jobs lists all background jobs in the current shell. fg brings a background job to the foreground. fg %2 brings job number 2 to the foreground. Ctrl+Z suspends (pauses) the current foreground process, and bg resumes it in the background.
Background jobs are tied to your shell session — if you close the terminal, they receive SIGHUP and typically terminate (unless you used nohup or disown). For persistent background execution, always use tmux.
& puts a job in the shell's background process group, but the job still inherits stdout/stderr. Closing the terminal sends SIGHUP to the entire process group. To truly detach: nohup cmd > log 2>&1 & — three operations (ignore SIGHUP, redirect output, background) — or use disown to remove the job from the shell's job table.
Signals
Linux signals control process behavior. The most important for ML work: SIGTERM (signal 15) asks a process to shut down gracefully — PyTorch signal handlers can catch this and save a checkpoint before exiting. SIGKILL (signal 9) forces immediate termination with absolutely no cleanup — the process cannot catch or ignore it. SIGSTOP pauses a process (useful for temporarily freeing CPU), and SIGCONT resumes it.
Always try SIGTERM (kill <PID>) before SIGKILL (kill -9 <PID>). SIGTERM gives the training script a chance to save progress. SIGKILL should be a last resort for completely stuck processes that don't respond to SIGTERM.
Linux defines standard signals (POSIX) plus real-time signals. SIGKILL (9) and SIGSTOP (19) cannot be caught or ignored — they're enforced by the kernel. Everything else can be handled with signal(2) or sigaction(2). The crucial property: signal delivery is asynchronous and not queued (except RT signals), so signals fired before handler runs collapse to 1.
Theory Exercise
Problem:
You SSH into a shared GPU server and need to investigate the following situation: nvidia-smi shows all 4 GPUs at 95%+ memory usage, but htop shows CPU usage is only 5%. Two of the GPU processes belong to your colleague who left for vacation yesterday. One GPU is running your training job that should have finished 6 hours ago. The fourth GPU has a process with a PID that doesn't correspond to any running user process in ps aux. Describe what's happening and how you would handle each of the four GPUs.
Hints:
- Consider the difference between a training job that's hung vs one that's still running slowly
- Think about what happens when a process dies but CUDA memory isn't released
- Consider the ethics and protocols of killing another user's processes on shared infrastructure
Five researchers share one 8-GPU server. Alice needs to read Bob's checkpoints, nobody should be able to read anybody else's API keys, and only one person should be able to upgrade the NVIDIA driver. The previous topic showed how to control processes. This one shows how Linux decides which user may touch which file and run which command.
Every process runs as a user, with a numeric user ID and a set of groups. Every file has an owning user and group and three permission sets. Groups are the tool for collaboration: put the team in an ml-team group, make shared directories group-writable, and set the setgid bit so new files inherit the group. umask sets the default permissions of new files, so a team-friendly umask avoids constant chmod fixes. sudo grants specific administrative commands to specific users, with every use logged, so the root password never needs sharing.
SSH keys control who can log in at all. Each person gets their own key pair, so access can be revoked individually when someone leaves. Least privilege runs through all of it: give each user and service exactly the access it needs.
Definition
Linux access control is discretionary and identity-based: each process carries a user ID and group IDs, and each file has an owner, a group and permission bits for owner, group and others. The kernel checks those bits on every access, root bypasses most checks, and sudo lets configured users run specific commands with elevated privileges.
In this topic
- 1Users & Home Directories
- 2Groups
- 3sudo
- 4File Ownership
- 5SSH Key Management
- 6umask
Users & Home Directories
Each user on a Linux system has a unique username, a numeric user ID (UID), a home directory (typically /home/username), and a default shell. The useradd command creates users, and passwd sets passwords. User account information is stored in /etc/passwd (readable by all), while encrypted passwords are in /etc/shadow (readable only by root).
On GPU servers, each team member should have their own user account. This provides isolation (each user's processes run under their own UID), accountability (you can track who ran which training jobs), and security (users can't accidentally access each other's API keys stored in home directories).
The kernel only knows numbers. A user ID is a 32-bit integer, and by convention IDs below 1000 are system accounts while human users start at 1000. A user's home directory is whatever the sixth field of their /etc/passwd line says; /home followed by the username is a convention, not a rule. Because files store the number, renaming a user changes nothing on disk, while two machines that give UID 1001 to different people will show each other's files under the wrong name on shared storage.
Groups
Groups are collections of users that share file access permissions. The groupadd command creates a group, and usermod -aG ml-team alice adds alice to the ml-team group without removing her from existing groups (the -a flag is critical — without it, usermod replaces all groups).
Groups enable collaborative access to datasets and models. Set up a shared /data/ directory owned by the ml-team group with permissions 2775 (the setgid bit ensures new files inherit the group), and every team member can read and write shared training data without needing root.
Each user has one primary group + up to NGROUPS_MAX ( on Linux) supplementary groups. Permission checks compare process credentials to file mode in where is the number of supplementary groups. The kernel caches group membership in the credentials struct, so the check is constant-time per syscall.
sudo
The sudo command runs a single command as root (or another user). The /etc/sudoers file controls who can use sudo and which commands they can run. Never share the root password — use sudo with individual accounts for accountability and complete audit trails.
The sudoers file supports granular control: alice ALL=(ALL) /usr/bin/apt, /usr/bin/pip allows alice to install packages but nothing else. %ml-team ALL=(ALL) /usr/bin/nvidia-smi allows the entire team to check GPU status with sudo.
sudo elevates privileges by setting the effective uid via setuid(0). The check happens once at sudo invocation; the resulting process runs as root with no further permission gates. This is why a sudo'd script with a bug is catastrophic — there are no further safeguards. The principle of least privilege says: only sudo the smallest possible command, never sudo bash.
File Ownership
Every file has a user owner and a group owner. New files inherit the creating user's ownership by default. The chown user:group file command changes ownership. The setgid bit (chmod g+s dir/) changes inheritance behavior for directories — new files in that directory inherit the directory's group rather than the creating user's primary group.
This is essential for shared workspaces. Without setgid, if Alice creates a checkpoint file in /data/models/, it belongs to alice:alice and Bob can't write to it. With setgid on /data/models/, the file belongs to alice:ml-team and Bob (as an ml-team member) has full access.
Every file has exactly one uid + one gid stored in the inode (8 bytes total). Permission resolution is: if st_uid == process.uid, use owner bits; else if st_gid is in process.groups, use group bits; else use other bits. First match wins — even if group bits are more permissive than owner bits, owner bits still apply.
SSH Key Management
Each team member should generate their own SSH key pair. The server's ~/.ssh/authorized_keys file (one per user) lists the public keys allowed to authenticate as that user. Revoking access means removing the corresponding public key from the file.
For teams: consider a centralized SSH key management approach. GitHub or GitLab can serve as a key authority (ssh-import-id gh:username imports keys from GitHub). For larger teams, an LDAP or FreeIPA server centralizes both user accounts and SSH keys, making onboarding and offboarding much simpler.
Two different permission checks are involved. On the server, sshd's StrictModes refuses keys if your home directory, ~/.ssh or authorized_keys is writable by group or others, because then someone else could add their own key; a world-readable authorized_keys is fine, since it holds only public keys. On the client, ssh ignores a private key file that other users can read, printing an 'unprotected private key file' warning, which is why chmod 600 fixes it.
umask
umask sets which permission bits are removed from newly created files and directories. Programs usually request 666 for files and 777 for directories, and the kernel clears every bit that is set in the umask. With umask 022, files become 644 and directories 755: readable by everyone, writable only by the owner. With 002, they become 664 and 775, so the group can write too, which suits shared project directories. It is a mask, not subtraction: umask 033 turns 666 into 644, not 633. Set it in your shell profile or in a systemd service's UMask setting.
umask is a 9-bit mask subtracted (bitwise AND with NOT) from the default mode at file creation. With umask 0022, a file created with default 0666 ends up as 0644 (rw-r--r--). Setting umask 0077 makes new files private to the owner — essential on multi-tenant boxes where the default world-readable mode would leak data.
Every experiment starts the same way: check the GPUs are free, activate the environment, choose devices, create a dated output directory, launch training and save the log. Typing that by hand invites mistakes, and nobody can repeat exactly what you ran last Tuesday. A shell script writes the sequence down once, so it runs the same way every time and records the parameters that were used.
The previous topics covered single commands and processes. A script combines them with programming constructs. Bash is the default scripting shell on most Linux systems, and a script is just a text file of commands with a shebang line naming its interpreter. Variables hold values, conditionals test files and strings, loops repeat work across GPUs or configs, and functions name reusable steps.
Shell has sharp edges. Unquoted variables split on spaces, a failed command does not stop the script unless you ask it to, and a failing first stage of a pipe is ignored by default. This topic covers the constructs and the defenses: quoting, set -euo pipefail, trap for cleanup, and ShellCheck, a static analyzer that catches most of these bugs before they run.
Definition
A shell script is a text file of commands executed by a shell interpreter, usually bash, named in its first line (the shebang). It adds variables, quoting and expansion rules, conditionals, loops, functions and exit statuses to ordinary commands, so command-line workflows can be automated, parameterized and repeated exactly.
In this topic
- 1Shebang Line
- 2Variables
- 3Conditionals
- 4Loops
- 5Functions
- 6Error Handling
Shebang Line
The first line of a shell script, #!/bin/bash, tells the operating system which interpreter to use for executing the file. This is called the shebang (or hashbang). Without it, the system tries to run the script with the current shell, which may have different behavior.
The more portable alternative #!/usr/bin/env bash searches the system PATH for bash rather than assuming a fixed location. This works on systems where bash might be installed at /usr/local/bin/bash instead of /bin/bash (common on macOS).
When a file is executed, the kernel reads its first bytes. If they are the two characters hash and exclamation mark, it runs the named interpreter with the script's path as an argument. Without them, the kernel's exec fails with ENOEXEC. Bash and other shells then quietly run the file themselves, so ./script.sh appears to work from a terminal, but the same file fails when started by Python's subprocess, by systemd or by anything else that calls exec directly. Always include the shebang.
Variables
Assign with name=value and no spaces around the equals sign, since spaces turn it into a command with arguments. Read a variable by writing a dollar sign before its name, or before the name in braces when text follows it. Always put expansions in double quotes. Unquoted, the value is split on whitespace and expanded as a glob, so a path with a space becomes two arguments. Command substitution, a dollar sign followed by a command in parentheses, captures that command's output, which is handy for timestamped experiment directories. The braces form also supplies defaults, so a script argument can fall back to a value when it is missing.
Bash variables are strings. Arithmetic happens only inside arithmetic expansion, written with a dollar sign and double parentheses, or the double-parenthesis command, and it is integer-only, so a learning rate such as 0.001 cannot be computed in bash; use awk, bc or Python. Quoting decides the number of arguments: an unquoted expansion of 'resnet 50' produces two words, while a quoted one produces exactly one. ShellCheck's SC2086 warning flags every unquoted expansion for this reason.
The following script fails when the MODEL variable contains a space (e.g., 'resnet 50'). Why, and how do you fix it?
MODEL=$1
mkdir experiments/$MODEL
Conditionals
Bash conditionals use if [ condition ]; then ... fi syntax. Test flags include: -f (file exists), -d (directory exists), -z (string is empty), -n (string is non-empty), -eq/-ne/-lt/-gt (numeric comparisons). Double brackets [[ ]] support regex matching and are generally preferred for their safer behavior (no word splitting inside).
For ML scripts, conditionals check prerequisites: does the CUDA device exist? Is the dataset directory populated? Is there enough disk space? These pre-flight checks prevent training jobs from failing hours into a run due to a missing file or full disk.
Both test forms produce an exit status, and if simply branches on whether it is zero. The single bracket is an ordinary command (a bash builtin, also installed as /usr/bin/test and /usr/bin/[), so its arguments are expanded and split first, and an unquoted empty variable can make it misread the test. The double bracket is shell syntax: no word splitting inside it, pattern matching with ==, regex with =~ and && and || inside the test. Use double brackets in bash scripts and single brackets only in sh scripts.
Loops
A for loop runs its body once per word: for gpu in 0 1 2 3, or for cfg in configs/*.yaml for every matching file. A while loop runs while a command succeeds; while IFS= read -r line, with a file redirected in, processes a file line by line without mangling backslashes or spaces. To launch runs in parallel, end the command inside the loop body with an ampersand, as in python train.py with a learning-rate argument followed by an ampersand, and then call wait after the loop so the script blocks until every run has finished. Writing an ampersand followed by a semicolon is a syntax error.
A for loop over a glob expands the whole list inside bash, which has no argument-length limit for its own loops. In a directory of 300,000 long file names, a test loop iterated all 300,000, while ls with the same glob failed with 'Argument list too long', because exec of an external command is limited by ARG_MAX (2 MB on the test machine). For huge directories, the limit to respect is exec, so use find with -exec ending in a plus sign, or xargs.
Functions
A function names a block of commands: define it as name() followed by the body in braces, then call it like any command with arguments, which it reads as positional parameters. It returns an exit status with return, 0 for success, so if check_gpu_free; then works naturally. To return data, print it and capture it with command substitution. Variables are global by default, so declare helpers' variables with local to avoid overwriting a caller's loop variable. Functions such as check_disk, make_run_dir and cleanup keep a growing launcher script readable and let you test steps one at a time.
Calling a function runs in the current shell, with no new process, so it is cheap and can change the caller's variables and working directory. Capturing its output with command substitution, however, runs it in a subshell, so any variables it sets are lost. That is the classic surprise: a function that both prints a result and updates a counter will update the counter only when called directly, not when its output is captured.
Error Handling
Three critical settings for robust scripts: set -e exits immediately on the first command that fails (non-zero exit code). set -u treats references to unset variables as errors (catches typos). set -o pipefail makes pipes fail if any command in the chain fails (by default, only the last command's exit code matters).
Combine them as set -euo pipefail at the top of every script. The trap command registers cleanup functions that run when the script exits (success or failure), ensuring temporary files are removed and background processes are killed. For ML scripts, trap can save a partial checkpoint or send a notification on failure.
set -e has documented exceptions. It does not trigger for a failure inside an if or while test, on the left of && or ||, or in any pipeline stage except the last. A test script with set -e called a function containing false from an if and kept going past the failing line, and a failing command substitution in an assignment also did not stop it. Without pipefail, false piped into true returned status 0; with it, 1. Treat set -euo pipefail as a safety net, and check critical commands explicitly.
A teammate upgrades NumPy for one project, and another project's training script starts failing. Someone runs sudo pip install, and a system tool breaks. A new CUDA toolkit goes in, and the PyTorch build no longer matches. Shared GPU servers turn dependency management into a team problem, which is why this topic follows users and permissions.
There are two layers. The system package manager (apt on Ubuntu and Debian, dnf on Fedora and Red Hat) installs operating system software, libraries and drivers, and owns the system Python. Python's own tools install project packages from PyPI, and they belong in isolated environments, never in the system interpreter. Modern distributions enforce that: pip refuses to install into the system Python unless forced.
For project environments, venv creates a lightweight isolated Python, conda and micromamba also manage non-Python libraries such as MKL or CUDA runtimes, and pyenv installs several Python versions side by side. Reproducibility comes from recording exactly what was installed, through pinned requirements files or lock files. The principle throughout is isolation: one environment per project, rebuilt from a file, never edited by hand on a shared machine.
Definition
Package management is installing, upgrading and removing software together with its dependencies. On Linux, system package managers such as apt manage operating-system packages from distribution repositories, while language tools such as pip and conda manage Python packages, ideally inside isolated environments described by pinned dependency files.
In this topic
- 1apt vs pip
- 2Virtual Environments
- 3conda
- 4Requirements Files
- 5pyenv
- 6Micromamba
apt vs pip
apt manages operating system packages: nginx, git, build-essential, libcudnn8. pip manages Python packages: torch, numpy, scikit-learn. These are separate ecosystems that should not be mixed carelessly.
Never run sudo pip install — this installs Python packages into the system Python, potentially breaking system tools (many Ubuntu system utilities are written in Python). Always install ML packages in a virtual environment or conda environment. The system Python should only be modified through apt.
Two package managers writing into one directory is a consistency problem. apt records which files it installed and which versions its packages require; pip knows nothing about that record and overwrites files silently. PEP 668 makes distributions mark the system interpreter as externally managed, so pip refuses to touch it, which on the test machine produced the externally-managed-environment error. The fix is not the override flag but a separate environment for each project.
A colleague ran sudo pip install numpy --upgrade on a shared GPU server. Now apt commands fail with Python import errors. What happened and how do you fix it?
Virtual Environments
Python's built-in venv module creates isolated Python environments. python -m venv myenv creates a new environment, and source myenv/bin/activate activates it. Once activated, all pip installs go into the venv directory, completely separate from the system Python. deactivate returns you to the system Python.
Venvs are lightweight (only symlink to the system Python and install packages separately) but limited — they can't install non-Python dependencies or manage different Python versions. For simple projects that only need pip packages, venvs are sufficient.
A venv is small because it copies almost nothing: a pyvenv.cfg file pointing at the base interpreter, a bin directory with links and activation scripts, and an empty site-packages. On the test machine a venv without pip took 56 KB and one with pip took 13 MB. Activation only prepends the venv's bin to PATH, so running the venv's python directly works without activating, which is the right way to call it from cron or systemd.
conda
conda is a cross-platform package and environment manager that installs both Python packages and native libraries, such as MKL, compilers or CUDA runtime libraries, from channels like conda-forge, without root. conda create -n proj python=3.11 makes an isolated environment with its own interpreter. That makes it useful when native dependencies are hard to get through pip. Two cautions for ML: PyTorch stopped publishing to its own conda channel in late 2024, so install PyTorch with pip inside the conda environment or from conda-forge. And mixing conda and pip installs can conflict, so install conda packages first and pip packages last.
Choosing package versions that satisfy every constraint is NP-complete in general, which is why conda's original pure-Python solver could take minutes on large environments. Since conda 23.10, the default solver is libmamba, built on the libsolv SAT library, which is much faster on the same problems. Hard cases remain: an environment with many loose, conflicting constraints can still be slow or unsolvable, and pinning a few key packages usually shrinks the search dramatically.
Requirements Files
Capturing your exact package versions ensures reproducibility. pip freeze > requirements.txt records every installed package and its version. pip install -r requirements.txt recreates the exact environment. For conda, conda env export > environment.yml captures both conda and pip packages.
For production, use pip-compile from the pip-tools package — it creates deterministic builds by resolving and pinning transitive dependencies. A simple requirements.in file listing only your direct dependencies (torch, opencv-python) gets compiled into a fully-pinned requirements.txt with every sub-dependency locked.
Pinning versions is necessary but not sufficient for reproducibility. pip freeze records exact versions, yet the same version can resolve to different wheel files on another platform or Python version, and a compromised or re-uploaded file would not be detected. Lock files from pip-compile with --generate-hashes, or from uv or Poetry, also record hashes, and pip install with --require-hashes then refuses anything else. Keep the short list of direct dependencies separately so that upgrades remain deliberate.
pyenv
pyenv installs and manages multiple Python versions on a single machine. pyenv install 3.11.5 compiles Python 3.11.5 from source. pyenv local 3.11.5 sets the Python version for the current directory. This is essential when different projects require different Python versions — one project might need Python 3.10 for TensorFlow compatibility while another uses Python 3.12 for newer features.
pyenv works by inserting shims into your PATH that intercept python and pip commands, routing them to the correct Python version. It pairs well with pyenv-virtualenv for managing per-project virtual environments tied to specific Python versions.
pyenv shims python to a wrapper that consults .python-version files in the cwd. Lookup walks up the directory tree (like git), so cost is where is depth from /. Installing a Python version compiles it from source — minutes on a modern CPU. Worth it for projects pinned to specific Python versions.
Micromamba
Micromamba is a C++ reimplementation of conda that's 10-100x faster at resolving and installing packages. It's a drop-in replacement: micromamba create -n myenv pytorch works just like conda. The speed improvement comes from a compiled solver (libmamba) versus conda's older Python-based solver.
Micromamba is ideal for Docker images (where install speed directly impacts build time), CI/CD pipelines, and anyone tired of waiting minutes for conda install to solve the environment. Recent conda versions (23.10+) also use the libmamba solver by default, narrowing the speed gap.
micromamba is a single MB statically-linked binary, vs miniconda's MB Python distribution. Cold-start install is s vs minutes. The win is in CI/CD: dropping a 400 MB download from every job to a 10 MB one saves bandwidth, time, and money proportional to your build frequency.
Related Problems on PixelBank
Two kinds of work keep a model in production without anyone watching. Some jobs must run on a schedule: retrain every night at 2 AM, prune checkpoints every Sunday, back up the experiment database twice a month. Others must run forever: the inference API should start at boot and come back within seconds if it crashes at 3 AM. The previous topic set up environments; this one keeps things running in them.
cron is the classic scheduler. Each crontab line has five time fields (minute, hour, day of month, month, day of week) followed by a command. It is simple and everywhere, but it runs jobs with a minimal environment, does not catch up on runs missed while the machine was off, and leaves logging to you.
systemd is the init system and service manager on most modern distributions. A unit file describes a service: what to run, as which user, with which environment and resource limits, and when to restart it. systemd timers can replace cron with catch-up, randomized delays and dependencies, and journalctl reads the logs of every service in one place. Together they turn manual babysitting into infrastructure that maintains itself.
Definition
cron is a daemon that runs commands at times given by five-field schedules in crontab files. systemd is the Linux init system and service manager: it starts, supervises, restarts and logs units described by declarative unit files, including services for long-running processes and timers for scheduled jobs, with logs collected in the journal.
In this topic
- 1Cron Syntax
- 2crontab
- 3Systemd Service Files
- 4Restart Policies
- 5journalctl
- 6Timer Units
Cron Syntax
A cron expression has five time fields: minute (0-59), hour (0-23), day-of-month (1-31), month (1-12), and day-of-week (0-7, where both 0 and 7 are Sunday). Special characters expand the syntax: * means any value, */5 means every 5th unit, 1,15 means the 1st and 15th, and 1-5 means a range from 1 to 5.
Common schedules: 0 2 * * * (daily at 2 AM), 0 */6 * * * (every 6 hours), 0 0 * * 0 (weekly on Sunday midnight), */15 * * * * (every 15 minutes). Use crontab.guru to verify expressions before deploying them.
Cron uses 5-field syntax: min hour dom mon dow. Minimum granularity is 1 minute, so the cron daemon polls only once per minute and runs all matching jobs. With entries, matching is per minute — cheap. The downside of 1-minute granularity: jobs running every 30 s need a different scheduler (systemd timers, or external).
Write cron expressions for: (a) Run model retraining every night at 2:30 AM, (b) Clean up checkpoints every Sunday at 3 AM, (c) Check GPU health every 10 minutes, (d) Backup the experiment database on the 1st and 15th of each month at midnight.
crontab
Each user has their own crontab, edited with crontab -e and listed with crontab -l. Important caveat: cron runs commands with a minimal environment — it doesn't load your .bashrc or .profile. This means conda environments aren't activated, custom PATH entries are missing, and environment variables aren't set.
To work around this, either use absolute paths for everything, source your profile at the start of cron scripts, or set environment variables directly in the crontab (e.g., CUDA_VISIBLE_DEVICES=0 before the job line).
cron's environment is tiny on purpose: on Debian and Ubuntu it sets HOME, LOGNAME and SHELL=/bin/sh, with PATH just /usr/bin:/bin, and it does not read your .bashrc. So conda activation, custom PATH entries and exported keys are all missing, which is why a script that works in your terminal fails from cron. Another trap from crontab(5): an unescaped percent sign in the command becomes a newline, so date with a +%Y format breaks unless each percent is escaped with a backslash.
Systemd Service Files
Systemd service files are INI-format configuration files placed in /etc/systemd/system/. They have three sections: [Unit] defines the service description and startup dependencies, [Service] specifies the command to run, the user, restart behavior, and environment variables, and [Install] determines when the service should start (usually multi-user.target for normal boot).
After creating or modifying a service file, run systemctl daemon-reload to make systemd aware of the changes. systemctl enable <service> sets it to start on boot, and systemctl start <service> starts it immediately.
A systemd unit file is INI-style with [Unit], [Service], [Install] sections. systemd parses unit files lazily on demand, per unit. The crucial advantage over init scripts: declarative restart policies, dependency ordering, resource limits via cgroups — none of which require writing imperative shell.
Restart Policies
Systemd's restart policies determine what happens when a service exits. Restart=always restarts on any exit (even code 0). Restart=on-failure restarts only on non-zero exit codes (appropriate for inference servers — don't restart on manual stop). RestartSec=5 waits 5 seconds between restart attempts. StartLimitBurst=5 with StartLimitIntervalSec=60 prevents rapid restart loops — if the service crashes 5 times in 60 seconds, systemd gives up.
For ML inference servers, use Restart=on-failure with RestartSec=10 to give the system time to release GPU memory between restarts.
Restarts are rate-limited, not backed off. With the defaults on the test machine, RestartSec is 100 ms and the start limit is 5 starts within 10 seconds, after which systemd marks the unit failed and stops trying until reset. A model server that crashes on start-up therefore exhausts the limit in about half a second. Set RestartSec to several seconds so that GPU memory and ports are released, and widen StartLimitIntervalSec. Since systemd 254, RestartSteps and RestartMaxDelaySec add a growing delay.
journalctl
journalctl reads the systemd journal, where all service output (both stdout and stderr) is logged. The -u flag filters by service name. -f follows in real-time (like tail -f). --since 'today' or --since '1 hour ago' limits the time range. --no-pager outputs directly to the terminal for piping.
All stdout and stderr from services configured with StandardOutput=journal go here automatically. This centralizes log management — you don't need to manage separate log files for each service.
The journal stores entries in binary files with indexes on fields such as the unit name, boot ID and timestamp. Filtering with -u and --since uses those indexes instead of scanning every line, so queries stay fast as logs grow, unlike grep over plain text files whose cost grows with total log size. The journal has size limits, by default a share of the disk set by SystemMaxUse, and rotates old entries away, so ship important logs elsewhere.
Timer Units
Systemd timer units are the modern alternative to cron. They're more verbose to configure but offer features cron lacks: random delays (to prevent thundering herd effects when multiple servers run the same job), dependency on other services (only run retraining after the data pipeline service completes), persistent scheduling (run missed jobs if the server was down), and monotonic timers (run 30 minutes after boot).
For simple periodic tasks, cron is fine. For tasks that need reliability guarantees (must run even if a scheduled time was missed) or complex dependencies, systemd timers are better.
A timer fires within its accuracy window, not at the exact second. AccuracySec defaults to 1 minute, so systemd may coalesce wake-ups anywhere in that minute to save power; set AccuracySec=1s when timing matters. RandomizedDelaySec adds a random offset so a hundred machines with the same schedule do not hit a shared data store at once. Persistent=true stores the last trigger time on disk, and on boot runs the job once if a scheduled time was missed while the machine was off.
Theory Exercise
Problem:
Design the automation architecture for an ML model serving platform with these requirements: (a) A FastAPI inference server that auto-restarts on crash and starts on boot, (b) Nightly model retraining that only runs after a data validation step succeeds, (c) Hourly checkpoint cleanup that keeps only the 5 most recent checkpoints, and (d) GPU health monitoring every 5 minutes that alerts the team if utilization drops to 0% for 3 consecutive checks. Specify whether each should use cron or systemd and why.
Hints:
- Think about which tasks need restart guarantees vs simple scheduling
- Consider dependencies between tasks (retraining depends on data validation)
- Think about statefulness — which tasks need to remember previous runs?
The same training script should run on a laptop with one GPU and a server with eight, log to a different experiment tracker in each place, and never contain an API key in its source code. The previous topic ran jobs under cron and systemd, which start programs with almost no environment of their own. This topic is about the mechanism that carries configuration into a process: environment variables.
Every process has an environment, a list of name and value strings inherited from its parent. The shell lets you set variables and export them to children, so a value set before python runs is visible to the script through os.environ. .env files keep a project's settings in one place outside Git, and direnv loads them automatically when you enter the directory.
ML tooling reads many variables. CUDA_VISIBLE_DEVICES chooses GPUs, CUDA_LAUNCH_BLOCKING helps debugging, MASTER_ADDR, WORLD_SIZE and RANK configure distributed training, and tracker keys authenticate logging. Variables are convenient but not secret: other tools can read them, so real secrets belong in a secrets manager or mounted files. envsubst fills templates from the environment at deploy time.
Definition
Environment variables are name and value strings passed to every process when it starts, inherited from its parent unless changed. Shells set them with export, and programs read them through functions such as getenv or os.environ. They configure behavior, such as GPU selection, paths and credentials, without changing code.
In this topic
- 1export
- 2.env Files
- 3CUDA Environment Variables
- 4direnv
- 5Secrets Management
- 6envsubst
export
The export command makes a shell variable available to child processes. Without export, variables are only visible in the current shell — they won't be inherited by Python scripts, training processes, or other programs you launch. export API_KEY=sk-xxx makes the key available to any process started from that shell.
In scripts that launch Python training, always export variables that the Python code needs: export CUDA_VISIBLE_DEVICES=0,1 before python train.py. Otherwise, PyTorch won't see the GPU restriction.
Without export, a variable is local to the current shell. With export, it's added to the process environment, inherited by all children. The environment is a NULL-terminated array passed to execve(), capped at ARG_MAX (2 MB on Linux) — hit it and exec fails with E2BIG. Bloated env is a real bug class on CI runners.
.env Files
A .env file stores key=value pairs in a plain text file, one per line. Python's python-dotenv library loads these with load_dotenv(), making them available through os.environ. This keeps configuration separate from code and makes it easy to switch between development and production settings.
Critical rule: always add .env to .gitignore. Create a .env.example file (with empty values) that documents which variables are needed. This lets new team members know what to configure without exposing actual secrets.
A .env file is just key=value lines, parsed by tools like direnv, dotenv-cli, or docker compose. Cost is a single file read at process startup — essentially free. The whole point is preventing secrets from being committed: .env is in .gitignore, the secret stays out of git history.
CUDA Environment Variables
CUDA behavior is controlled through several environment variables. CUDA_VISIBLE_DEVICES selects which GPUs a process can see (e.g., '0,1' for the first two GPUs). CUDA_LAUNCH_BLOCKING=1 makes CUDA operations synchronous, essential for debugging — without it, CUDA errors appear at a later operation than where they actually occurred.
TORCH_CUDA_ARCH_LIST sets which GPU architectures to target when compiling CUDA kernels (e.g., '7.0;8.0;8.6' for V100, A100, and RTX 3090). Setting this correctly avoids compiling for unnecessary architectures, significantly reducing compilation time.
CUDA_VISIBLE_DEVICES=0,2 makes only GPUs 0 and 2 visible to CUDA — the runtime renumbers them as cuda:0 and cuda:1. This is the cheapest GPU isolation: one env var, no kernel changes, at process start. Combined with MPS for fractional sharing, it's how multi-tenant GPU boxes avoid OOM.
You run two training jobs on a 4-GPU server, but both jobs try to use all 4 GPUs and crash with CUDA out-of-memory errors. How do you assign 2 GPUs to each job?
direnv
direnv automatically loads and unloads environment variables when you enter and leave a project directory. Create a .envrc file with dotenv (to load .env files) or export VAR=value directives. When you cd into the directory, variables are set; when you cd out, they're removed.
This is incredibly convenient for ML engineers working on multiple projects with different API keys, CUDA configurations, and virtual environments. Each project directory has its own .envrc that sets everything up automatically — no manual activation or sourcing required.
direnv hooks into your shell prompt and loads .envrc whenever you cd into a directory. Trust model: .envrc must be explicitly direnv allow'd before it executes — preventing a malicious repo from running shell code on cd. Lookup walks up the directory tree like git, .
Secrets Management
For development, .env files are adequate. For production, use dedicated secrets managers: AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault. These provide access control (who can read which secrets), audit trails (who accessed what and when), automatic rotation (change API keys without redeploying), and encryption at rest.
Note that environment variables are not truly secret on a Linux machine — they're visible in /proc/PID/environ to anyone with access to the process. In high-security environments, inject secrets through mounted files or in-memory vaults rather than environment variables.
An environment variable is copied to every child process unless removed, so a key exported in a launcher reaches every tool the job starts, including third-party ones, and frameworks often dump the environment in crash reports. On Linux, /proc/PID/environ is readable only by the process owner and root (mode 400 on the test machine), which limits exposure on a shared server. Mounted secret files and short-lived credentials narrow it further, and a secrets manager adds rotation and an audit log.
envsubst
envsubst, from GNU gettext, copies standard input to standard output and replaces references to environment variables, written in shell style with a dollar sign, with their values. envsubst with config.template.yml redirected in and config.yml redirected out produces a filled-in config at deploy time. Commit the template, not the result, so real values stay out of Git. Two cautions. Unset variables become empty strings without any error, so check required variables first. And it replaces every reference it sees, so pass a list of the allowed variable names as its argument to leave other text untouched.
Without arguments, envsubst substitutes every variable reference in the input. A template that legitimately contains shell-like text, such as an nginx config referring to its own variables or a script fragment, gets those references replaced with empty strings, silently breaking the output. Passing a single argument that names only the intended variables restricts substitution to that list. Validate the generated file, for example by parsing the YAML, before deploying it.