--- title: "Linux, Shell & Docker: The Daily Toolkit" author: "Pradeep Kumar" published: "2026-11-02T10:30:00.069Z" updated: "2026-11-02T10:17:35.164Z" url: "https://zyvop.com/linux-shell-docker-the-daily-toolkit-0w70o" canonical: "https://zyvop.com/linux-shell-docker-the-daily-toolkit-0w70o" tags: ["Docker","linux","AI Engineer","terminal","shell"] --- # Linux, Shell & Docker: The Daily Toolkit Get comfortable in the terminal: Linux commands, pipes, permissions, shell scripts, SSH, and Docker, ending with a real API packaged in a container and checked by a smoke test. ![Cover image]() Here's a prediction. Within a few months of working on AI projects, you'll be typing commands into a black window with a blinking cursor. You'll connect to a remote machine that has a GPU, start a training job that runs for hours, check why it stopped, read a log file with a million lines, and package your app so a teammate can run it with one command. All of that happens in a **terminal**, usually on **Linux**, and increasingly inside **Docker containers**. Servers, cloud machines, GPU boxes, CI systems, and most deployed AI apps run Linux. This post is core, and it's the practical toolkit for that world. It also pulls together a lot of things from earlier: processes and pipes from Post 19, environments from Post 12, and the server we built in Post 20. A note on what I tested. I ran the shell commands in this post in real Bash on Ubuntu 24.04, so those outputs are genuine. I couldn't run Docker, SSH, or tmux in my test environment, so those sections come from my knowledge and I'll say so when we get there. ## The terminal, the shell, and why bother People use these words loosely, but they're different things: - The **terminal** is the window you type into. - The **shell** is the program inside it that reads what you type and runs it. The common ones are **Bash** and **zsh**. (On macOS the default is zsh, which works much like Bash.) Why use a text window in an age of buttons? Because commands can be **combined, repeated, saved in scripts, and run on machines you can't see**. A remote server has no screen for you to click on. And typing `git switch -c fix-bug` is faster than hunting through menus, once you know it. A quick word for different systems: - **Linux**: you're already home. - **macOS**: it's Unix underneath, so almost everything here works. A few tools differ slightly (for example `sed -i` behaves differently), so if a command fails, check the difference between the Linux and macOS versions. - **Windows**: install **WSL** (Windows Subsystem for Linux), which gives you a real Linux inside Windows. In a PowerShell window, `wsl --install` sets it up. Do your AI work there. Most tutorials, tools, and servers assume Linux. A few keyboard shortcuts will make you feel fast on day one: | Keys | What they do | | --- | --- | | **Tab** | Auto-complete a file or command name. Press it constantly. | | **↑** | Bring back the previous command | | **Ctrl + R** | Search your command history | | **Ctrl + C** | Stop the running program | | **Ctrl + D** | End input, or exit the shell | | **Ctrl + L** | Clear the screen | | **Ctrl + A / Ctrl + E** | Jump to the start or end of the line | And when you meet a command you don't know, ask it: `command --help` prints a short summary, and `man command` opens the full manual (press `q` to quit). ## Finding your way around The shell is always "in" a folder, called the **current working directory**. These commands show and change it: ```bash pwd # print where I am ls # list files here ls -la # list everything, with details, including hidden files cd projects/textkit # move into a folder cd .. # move up one level cd ~ # go to my home folder cd - # go back to where I just was ``` Some vocabulary that trips people up: - An **absolute path** starts from the root, `/`, like `/home/asha/projects`. A **relative path** starts from where you are, like `projects/textkit`. - `.` means "this folder" and `..` means "the folder above." - `~` is shorthand for your home folder. - Files whose names start with a dot, like `.env` and `.gitignore`, are **hidden**. `ls` skips them, and `ls -a` shows them. Here's `ls -la` on a project folder (you'll see your own username in place of `asha`): ```yaml drwxr-xr-x 4 asha asha 4096 Nov 2 10:07 . drwxr-xr-x 4 asha asha 4096 Nov 2 10:07 .. -rw-r--r-- 1 asha asha 0 Nov 2 10:07 .env -rw-r--r-- 1 asha asha 0 Nov 2 10:07 README.md drwxr-xr-x 2 asha asha 4096 Nov 2 10:07 src drwxr-xr-x 2 asha asha 4096 Nov 2 10:07 tests ``` The first column is the permissions, which we'll decode in a moment. Folders start with `d`. You'll also see the same handful of locations on any Linux machine: | Folder | What lives there | | --- | --- | | `/home/yourname` | Your files (also written `~`) | | `/tmp` | Temporary files that can be thrown away | | `/etc` | System configuration | | `/var/log` | Log files | | `/usr/bin`, `/bin` | Programs | | `/mnt`, `/media` | Attached drives | ## Working with files ```bash mkdir -p projects/textkit/src # make folders (-p creates the parents too) touch README.md # create an empty file, or update its timestamp cp main.py backup.py # copy mv backup.py notes/old_main.py # move or rename rm old.txt # delete (no recycle bin!) rm -r old_folder # delete a folder and everything in it ``` A serious warning about `rm`. There is **no trash can**. A deleted file is gone. `rm -rf` (recursive and forced, no questions asked) is how people delete things they never meant to. Never run it with a variable you haven't checked. If a script says `rm -rf "$DIR/"` and `DIR` happens to be empty, the command becomes `rm -rf /`, which tries to delete the whole system. (We'll see how to guard against this below.) To look inside files: ```bash cat sample.txt # print the whole file head -n 2 sample.txt # the first 2 lines tail -n 2 sample.txt # the last 2 lines tail -f server.log # keep watching a file as new lines arrive (Ctrl+C to stop) wc -l sample.txt # count lines (use -w for words) less big.log # scroll through a long file (q to quit) ``` `tail -f` is one you'll use constantly, to watch a log while your program runs. And for sizes and space: ```bash du -sh projects # how big is this folder? df -h # how full are the disks? free -h # how much memory is free? ``` (`free -h` is the quick way to check the memory arithmetic from Post 19: how much RAM do I actually have?) ## Searching and slicing text Linux comes with small tools that each do one job well, and they shine when you chain them with pipes (`|`), from Post 19. To practice, here's a log file in the same format as our server from Post 20. Save it as `server.log`: ```typescript 127.0.0.1 - - [30/Oct/2026 09:27:30] "GET /health HTTP/1.1" 200 - 127.0.0.1 - - [30/Oct/2026 09:27:31] "POST /ask HTTP/1.1" 200 - 127.0.0.1 - - [30/Oct/2026 09:27:32] "POST /ask HTTP/1.1" 422 - 127.0.0.1 - - [30/Oct/2026 09:27:33] "GET /flaky HTTP/1.1" 429 - 127.0.0.1 - - [30/Oct/2026 09:27:34] "GET /flaky HTTP/1.1" 429 - 127.0.0.1 - - [30/Oct/2026 09:27:35] "GET /flaky HTTP/1.1" 200 - 127.0.0.1 - - [30/Oct/2026 09:27:36] "POST /stream HTTP/1.1" 200 - 127.0.0.1 - - [30/Oct/2026 09:27:37] "GET /nope HTTP/1.1" 404 - 127.0.0.1 - - [30/Oct/2026 09:27:38] "POST /ask HTTP/1.1" 200 - ``` ### grep: find lines that match ```bash grep "429" server.log # lines containing 429 grep -c "POST" server.log # how many lines? (4) grep -n "/ask" server.log # show line numbers grep -v " 200 " server.log # lines that do NOT match (-v inverts) grep -i "get /HEALTH" server.log # ignore case grep -E "(404|422)" server.log # either pattern (-E for extended patterns) grep -r "TODO" src/ # search every file in a folder ``` `grep -r` is how you find where something is used across a whole project. ### find: locate files ```bash find . -name "*.py" # every Python file below here find . -type f -name "*.md" # only regular files find . -name ".*" -type f # hidden files find . -size +100M # files bigger than 100 MB find . -mtime -1 # changed in the last day ``` ### sort, uniq, cut, tr, awk, sed These reshape text. The classic combo for counting things is `sort | uniq -c | sort -rn`. Here's every status code in our log, counted. The `awk` part pulls out the second-to-last field of each line: ```bash awk '{print $(NF-1)}' server.log | sort | uniq -c | sort -rn ``` ``` 5 200 2 429 1 422 1 404 ``` `awk` splits each line into fields (`$1`, `$2`, ...), and `NF` is the number of fields, so `$(NF-1)` is the one before the last. Read the pipeline left to right. It prints each status code, sorts them so identical ones sit together, `uniq -c` counts runs of identical lines, and `sort -rn` puts the biggest count first. (When two counts tie, the order they appear in can vary.) You can do the same for which paths are hit most: ```bash awk '{print $7}' server.log | sort | uniq -c | sort -rn | head -3 ``` A few more small tools: ```bash echo "a,b,c" | cut -d, -f2 # b (pick a column) echo "Hello World" | tr 'a-z' 'A-Z' # HELLO WORLD (translate characters) echo "model=old-model" | sed 's/old/new/' # model=new-model (find and replace) printf "a.txt\nb.txt\n" | xargs -I{} echo "would process {}" # run a command per input line ``` And the word counter from Post 5, in one line of shell: ```bash printf "the cat sat on the mat\nthe dog sat on the log\n" | tr ' ' '\n' | sort | uniq -c | sort -rn | head -3 ``` ```csharp 4 the 2 sat 2 on ``` If you work with JSON a lot, a tool called `jq` filters and reshapes it from the command line. It isn't installed by default, and I haven't run it here, but it's worth knowing about. ## Permissions Every file has an owner, and rules about who can read, write, or run it. That first column of `ls -l` is the key: ```css -rwxr-xr-x 1 asha asha 47 Nov 2 10:08 hello.sh ``` Read it in pieces. The first character is the type (`-` for a file, `d` for a folder). Then three groups of three letters: what the **owner**, the **group**, and **everyone else** can do. In each group, `r` is read, `w` is write, `x` is execute (run), and `-` means "not allowed." So `rwxr-xr-x` means: the owner can read, write, and run it, while the group and others can read and run it, but not change it. You change permissions with `chmod`. The most common use is making a script runnable. A fresh script isn't: ```bash printf '#!/usr/bin/env bash\necho "hello from a script"\n' > hello.sh ./hello.sh ``` ```bash bash: ./hello.sh: Permission denied ``` ```bash chmod +x hello.sh ./hello.sh ``` ```css hello from a script ``` (The first line of the script, `#!/usr/bin/env bash`, is called a **shebang**. It tells the system which program should run the file.) Permissions also come as numbers, which you'll see in documentation. Each of read, write, and execute is worth 4, 2, and 1, and you add them: 7 is all three, 6 is read and write, 5 is read and run, 4 is read only. Three digits for owner, group, and others: | Number | Looks like | Typical use | | --- | --- | --- | | `755` | `rwxr-xr-x` | Programs and scripts: the owner changes them, everyone runs them | | `644` | `rw-r--r--` | Normal files | | `600` | `rw-------` | Private files: only the owner can touch them | That last one matters for AI work. **Private keys and secret files should be** `600`. SSH refuses to use a key file that other users can read. Finally, `sudo` runs a command as the administrator. When a command says "permission denied" it's tempting to just add `sudo`. Don't, unless you understand why, because it can change files in ways that are hard to undo, and it's rarely the right fix for a problem in your own project. ## Processes and long-running jobs Remember from Post 19 that a running program is a **process**. These tools let you see and control them: ```bash ps aux # list every process top # a live view of what's using CPU and memory (q to quit) kill 1234 # politely ask process 1234 to stop kill -9 1234 # force it to stop (a last resort) ``` `top` (or the friendlier `htop`, if it's installed) is the first place to look when a machine is slow. Is something using all the memory? All the CPU? On a GPU machine, `nvidia-smi` shows the GPU's memory use and which programs are using it, and `watch -n 1 nvidia-smi` refreshes it every second. (I don't have a GPU in my test environment, so I haven't run it, but it's the standard tool.) ### Background jobs Adding `&` to a command runs it in the background so you get your prompt back: ```bash sleep 30 & jobs # list background jobs kill %1 # stop job number 1 ``` ### The problem with long jobs and SSH Here's a trap that costs people hours of work. You connect to a remote machine, start a training run that takes six hours, then your laptop sleeps or your Wi-Fi drops. The connection ends, and your shell takes the job with it. The training dies halfway. There are two standard fixes: ```bash nohup python train.py > train.log 2>&1 & # keep running after I disconnect ``` or, better, use **tmux**, a tool that keeps a terminal session alive on the server: ```bash tmux new -s train # start a named session, then run your job inside it # press Ctrl+b, then d, to detach. The job keeps running. tmux attach -t train # come back later and pick up where you left off ``` (I couldn't run `tmux` in my test environment, so check its manual for details.) Either way, the important habit is to **never run a long job in a plain SSH session you might lose.** ### Exit codes, again Every command leaves an **exit code**, where 0 means success. The shell keeps the last one in `$?`: ```bash true; echo $? # 0 false; echo $? # 1 ls /does/not/exist 2>/dev/null; echo $? # 2 ``` When you kill a process with the default signal, it exits with code 143, and a program killed for running out of memory dies with 137. Seeing those numbers in a log gives you a clue about what happened. And `&&` and `||` use exit codes to chain commands: ```bash grep -q "line one" sample.txt && echo "found it" grep -q "nothing" sample.txt || echo "not found" ``` `&&` runs the next command only if the previous one succeeded, and `||` only if it failed. ## Environment variables and PATH You met environment variables in Post 12. In the shell: ```bash export MODEL_NAME="demo-model" # set a variable and pass it to programs I start echo "$MODEL_NAME" # demo-model bash -c 'echo "child sees: $MODEL_NAME"' # child sees: demo-model unset MODEL_NAME ``` `export` is the key word. Without it, a variable stays inside the shell, and programs you launch can't see it. You can also set a variable for a single command only: ```bash MODEL_NAME=oneoff bash -c 'echo "$MODEL_NAME"' # oneoff ``` After that command, `MODEL_NAME` is not set in your shell. To load a `.env` file full of `KEY=value` lines into your shell, use `set -a`, which marks everything defined afterward as exported: ```bash set -a; source .env; set +a ``` One more variable deserves a spotlight: `PATH`. It's a list of folders, separated by colons, that the shell searches when you type a command name: ```bash echo "$PATH" | tr ':' '\n' which python3 # shows which one the shell will run ``` If a command says "command not found," the program isn't in any `PATH` folder. And this is exactly how the virtual environments from Post 12 work: activating one puts its folder at the _front_ of `PATH`, so `python` resolves to the environment's copy. You can add your own tools the same way. A script saved as `bin/mytool` and made executable gives `mytool: command not found` until you do `export PATH="$PWD/bin:$PATH"`, and then it runs. To make a change permanent, add the `export` line to your `~/.bashrc` (or `~/.zshrc` on macOS). ## Redirection and pipes Post 19 introduced the three channels every program has: standard input, standard output (`stdout`), and standard error (`stderr`). Here's the full set of tools for steering them: | Symbol | What it does | | --- | --- | | `>` | Send stdout to a file (overwrites) | | `>>` | Send stdout to a file (appends) | | `2>` | Send stderr to a file | | `2>&1` | Send stderr to the same place as stdout | | `<` | Read stdin from a file | | `\|` | Pipe one command's stdout into the next one's stdin | | `tee` | Copy the stream to a file _and_ pass it along | Take a script that talks on both channels: ```bash #!/usr/bin/env bash echo "normal output" echo "an error message" >&2 ``` ```bash ./noisy.sh > out.txt 2> err.txt # separate files: out.txt has the normal line, err.txt the error ./noisy.sh > both.txt 2>&1 # one file with both ./noisy.sh 2>/dev/null # hide the errors (/dev/null is a black hole) ./noisy.sh 2>&1 | tee run.log # watch it live AND save it ``` The last one, `2>&1 | tee run.log`, is the one to remember. It's how you watch a long job's output on screen while also keeping a record. ## Shell scripts When you find yourself typing the same commands every day, put them in a script. Let's write one that shows the main ideas. Save it as `demo.sh`: ```bash #!/usr/bin/env bash set -euo pipefail name="${1:-world}" echo "Hello, $name!" for file in *.txt; do echo "found: $file" done count=0 for n in 1 2 3; do count=$((count + n)) done echo "sum is $count" greet() { local who="$1" echo "Hi, $who" } greet "Asha" if [ -f sample.txt ]; then echo "sample.txt exists" else echo "sample.txt is missing" fi ``` Run it with `chmod +x demo.sh` and `./demo.sh Ravi`. It prints `Hello, Ravi!`, lists the `.txt` files in the folder, prints `sum is 6`, `Hi, Asha`, and so on. Here's what each part does: - `"$1"`**,** `"${1:-world}"`: the first argument you passed in. The `:-world` part supplies a default if there isn't one. `"$@"` means all the arguments. - `for ... do ... done` loops. `$(( ... ))` does arithmetic. - **Functions** work like Python's, and `local` keeps a variable inside. - `[ -f file ]` tests whether a file exists. (`-d` is for folders, `-z` for an empty string.) ### The first line that matters: `set -euo pipefail` Shell scripts have a dangerous default: **they keep going after errors**. That line turns on strict mode, and every part of it prevents a real problem: ```bash bash -c 'false; echo "still running after a failure"' # still running after a failure ``` - `set -e` stops the script when a command fails. Without it, the line above sails on. With it, the script exits. - `set -u` treats an unset variable as an error, which catches typos. `echo "$MISSPELLED_VAR"` would otherwise quietly print nothing, and `rm -rf "$DIR/"` with an empty `DIR` would target `/`. - `set -o pipefail` makes a pipeline fail if _any_ step fails, not just the last one. Without it, `false | cat` reports success. For the `rm` danger specifically, there's a neat guard: `"${DIR:?must be set}"` stops the script with an error message if `DIR` is empty or unset. ### Always quote your variables This is the single most common shell bug. Watch what happens with a folder that has a space in its name: ```bash dir="my notes" ls $dir # ls: cannot access 'my': No such file or directory # ls: cannot access 'notes': No such file or directory ls "$dir" # draft.txt ``` Without quotes, the shell splits `my notes` into two words. **Write** `"$variable"` **with quotes, always**, unless you have a specific reason not to. ### Shell or Python? A rule of thumb: shell is great for gluing commands together and small automation. When a script grows past about fifty lines, needs real data structures, or has tricky logic, switch to Python. You already have the tools: `subprocess` from Post 19 lets Python run any shell command. A security note while we're here. You'll see installation instructions like `curl https://example.com/install.sh | bash`. That downloads a script and runs it immediately, with whatever it contains. Many legitimate tools use it, but it means trusting that server completely. If you can, download the script and read it first, and only do it with sources you trust. ## SSH: working on other machines **SSH** (Secure Shell) gives you a terminal on a remote machine, such as a cloud GPU box. I don't have an SSH server to test against, so what follows comes from knowledge, but these are the standard commands. You log in with a **key pair**: a private key that stays on your computer, and a public key you give to the server (or to GitHub, which also uses SSH keys). Create one: ```bash ssh-keygen -t ed25519 -C "you@example.com" ``` This makes two files in `~/.ssh/`: the private key (`id_ed25519`, **never share it**, and keep it `600`) and the public key (`id_ed25519.pub`, safe to share). Then: ```bash ssh asha@gpu-box.example.com # log in scp results.csv asha@gpu-box.example.com:~/ # copy a file to the server rsync -avz ./data/ asha@gpu-box.example.com:~/data/ # sync a folder (only sends what changed) ssh -L 8888:localhost:8888 asha@gpu-box.example.com # forward a remote port to my laptop ``` The last one is handy: if Jupyter or TensorBoard is running on the remote machine at port 8888, that command lets you open `http://localhost:8888` in your own browser and see it. To avoid typing long addresses, add an alias in `~/.ssh/config`: ```typescript Host gpu HostName gpu-box.example.com User asha IdentityFile ~/.ssh/id_ed25519 ``` After that, `ssh gpu` is all you need. ## Docker: packaging your app Now the last big tool. Back in Post 12, we dealt with "it works on my machine" by giving every project its own Python libraries. But the problem goes deeper: your machine also has a particular operating system, system libraries, and settings. **Docker** solves it by packaging your app _together with its whole environment_ into something that runs the same way on any machine that has Docker. Everything in this section comes from my knowledge. Docker wasn't available in my test environment, so I haven't run these commands. Check them against Docker's documentation as you go. ### The ideas Remember from Post 19 that a container isn't a miniature computer. It's an ordinary process that the operating system has _isolated_, so it sees its own filesystem and limited resources, while sharing the host's kernel. That's why containers start in a moment and are small compared with virtual machines. Three words to keep straight: - An **image** is a read-only template: your app, its libraries, and a minimal operating system, frozen in layers. - A **container** is a running instance of an image. - A **Dockerfile** is the recipe for building an image. Images are shared through **registries**, like Docker Hub, the way code is shared through GitHub. Install Docker Desktop (on Windows and macOS) or Docker Engine (on Linux), then check: ```bash docker --version docker run --rm python:3.12-slim python -c "print('hi from a container')" ``` The second command downloads a small Python image the first time, starts a container from it, runs one line of Python, and removes the container (`--rm`) when it finishes. ### The commands you'll use | Command | What it does | | --- | --- | | `docker build -t myapp .` | Build an image from the Dockerfile in this folder, and call it `myapp` | | `docker run myapp` | Start a container from the image | | `docker run -it myapp bash` | Start one and get an interactive shell inside it | | `docker run -d --name web myapp` | Run in the background, with a name | | `docker run -p 8000:8000 myapp` | Publish a port: `host:container` | | `docker run -e KEY=value myapp` | Set an environment variable | | `docker run -v mydata:/data myapp` | Attach a volume (persistent storage) | | `docker ps` | List running containers (`-a` for all) | | `docker logs -f web` | Watch a container's output | | `docker exec -it web bash` | Open a shell inside a running container | | `docker stop web` / `docker rm web` | Stop it, then remove it | | `docker images` | List images | | `docker system prune` | Clean up unused stuff | ### A Dockerfile Here's a typical one for a Python app that has dependencies: ```dockerfile FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "app.py"] ``` Each instruction does one job: - `FROM` picks the base image. The `-slim` versions are much smaller than the full ones. Pin a specific version (`3.12`) so rebuilds don't change under you. - `WORKDIR` sets the folder inside the container. - `COPY` copies files from your machine into the image. - `RUN` runs a command during the build, such as installing libraries. - `CMD` is the command that runs when a container starts. Notice the order. We copy `requirements.txt` and install _first_, and copy the rest of the code _afterward_. That's deliberate, and it's the most useful Docker trick there is. Docker caches each step as a **layer**. If a layer's inputs haven't changed, it reuses the cached result. Your code changes constantly, but your requirements rarely do. So with this order, a rebuild after editing code skips the slow library install and finishes in seconds. If you copied everything first, every code change would reinstall all your libraries. A few other habits: - Add a `.dockerignore` file, like `.gitignore`, listing things to keep out of the image: `.git`, `.venv`, `__pycache__/`, `.env`, data files. - **Never put secrets in an image.** Don't copy `.env` in, and don't write `ENV API_KEY=...` in the Dockerfile. Anything in an image can be read by whoever gets it. Pass secrets when you _run_ the container: `docker run -e MODEL_API_KEY ...` (which passes your shell's value through) or `--env-file .env`. - **Run as a non-root user** inside the container when you can. It limits the damage if something goes wrong. - **One main process per container.** If you need a database too, that's a second container (see Compose below). ### Three gotchas that catch everyone **1\. Containers forget.** A container's own filesystem disappears when it's removed. Anything you want to keep, like a SQLite file from Post 21 or downloaded model weights, must live in a **volume** (`-v mydata:/data`) or a **bind mount** that maps a folder on your machine into the container (`-v "$(pwd)/data:/data"`). **2\.** `localhost` **means something different inside.** Inside a container, `localhost` is the _container itself_. If your server listens only on `localhost`, then nothing outside the container, including your own browser, can reach it, even with `-p 8000:8000`. The server must listen on `0.0.0.0`, which means "all network interfaces." I tested this idea on my own machine, since it's about how network binding works, not about Docker. I ran our server bound to `localhost`, then tried to reach it using the machine's other network address: ```yaml via 192.0.2.2: http 000 <- connection failed ``` Then I bound it to `0.0.0.0` and tried again: ```yaml via localhost: http 200 via 192.0.2.2: http 200 ``` When it listens only on `localhost`, only the machine itself can reach it. Bound to `0.0.0.0`, it's reachable from outside too. A container boundary behaves the same way. In practice, you'll typically see "connection reset" or "empty reply" from the host when you forget this, with logs inside the container looking perfectly healthy. **3\. Python buffers its output when it isn't talking to a terminal.** This one hurts when you debug. Run `docker logs` on a healthy container and see nothing, because `print` output is sitting in Python's buffer. Here's a script that prints `start`, waits two seconds, then prints `end`: ```python import time print("start") time.sleep(2) print("end") ``` When I piped its output into another program, the way Docker captures output, with Python's default settings, both lines showed up at the same moment, after the two-second wait. Three fixes worked: running `python -u`, setting the environment variable `PYTHONUNBUFFERED=1`, or `print("start", flush=True)`. In a Dockerfile, the easiest is `ENV PYTHONUNBUFFERED=1`. ### GPUs and AI images For AI work, containers can use a machine's GPUs, with some setup. The host needs the NVIDIA drivers and the NVIDIA Container Toolkit, and then you add `--gpus all`: ```bash docker run --rm --gpus all some-gpu-image nvidia-smi ``` You'd normally start from a base image that already has the GPU libraries, such as the official PyTorch or NVIDIA CUDA images. They can be several gigabytes, and their tags change often, so look up the current ones. Don't bake huge model weights into an image if you can avoid it. Download them when the container starts, or mount them from a volume. ### Docker Compose: several containers together Real apps have more than one piece. A web app and a Postgres database (Post 21) are two containers. **Docker Compose** describes them in one file, `compose.yaml`, and starts them together: ```yaml services: db: image: postgres:16 environment: POSTGRES_PASSWORD: secret volumes: - pgdata:/var/lib/postgresql/data app: build: . environment: DATABASE_URL: postgresql://postgres:secret@db:5432/postgres ports: - "8000:8000" depends_on: - db volumes: pgdata: ``` `docker compose up` starts both, and `docker compose down` stops them. Notice that the app reaches the database at the hostname `db`, which is the service's name. Compose puts the containers on a private network where they can find each other by name. (That password is for local practice only. Real passwords come from secrets, not from files you commit.) ## Mini project: put the API in a container Let's package the server from Post 20. There are three parts: make the server configurable, write a smoke test in shell, and write the Docker files. ### Part 1: make the server configurable In `server.py`, add `import os` at the top (next to `import json`), and replace the final block with one that reads its address from environment variables: ```python if __name__ == "__main__": host = os.environ.get("HOST", "localhost") port = int(os.environ.get("PORT", "8000")) server = ThreadingHTTPServer((host, port), Handler) print(f"Listening on http://{host}:{port}", flush=True) server.serve_forever() ``` That gives us a safe default (`localhost`) for local work, and lets the container switch to `0.0.0.0`. Note the `flush=True`, from the buffering gotcha. You can test it right now, without Docker: ```bash HOST=localhost PORT=8123 python server.py ``` ### Part 2: a smoke test in shell A **smoke test** is a quick check that the basics work. Create `scripts/smoke_test.sh`: ```bash #!/usr/bin/env bash set -euo pipefail BASE="${1:-http://localhost:8000}" echo "Checking $BASE ..." for attempt in 1 2 3 4 5; do if curl -fsS "$BASE/health" > /dev/null 2>&1; then break fi echo "Not ready yet (attempt $attempt), waiting..." sleep 1 done status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health" || true) if [ "$status" -ne 200 ]; then echo "FAIL: /health returned $status" >&2 exit 1 fi answer=$(curl -fsS -X POST "$BASE/ask" -H "Content-Type: application/json" -d '{"question": "hi"}') echo "Answer: $answer" echo "OK" ``` Run `chmod +x scripts/smoke_test.sh`. A few details are worth understanding: - It takes the address as an optional argument, so the same script tests your local server _or_ a container. - It **waits and retries** for the server to come up, because a freshly started server (or container) isn't ready instantly. - `curl -f` makes curl fail on HTTP errors, `-s` silences progress output, `-S` still shows errors, and `-w "%{http_code}"` prints just the status code. - The `|| true` on the status line matters. With `set -e`, a failing `curl` (server down) would kill the script silently. With `|| true`, we get a status of `000`, and our own clear error message. - It ends with a meaningful **exit code**: 0 if everything's fine, 1 if not, which is exactly what CI systems look for (Post 28). Test it both ways. With the server **not** running: ```java Checking http://localhost:8000 ... Not ready yet (attempt 1), waiting... Not ready yet (attempt 2), waiting... Not ready yet (attempt 3), waiting... Not ready yet (attempt 4), waiting... Not ready yet (attempt 5), waiting... FAIL: /health returned 000 ``` and the exit code is 1. Then start the server in one terminal and run the script in another: ```typescript Checking http://localhost:8123 ... Answer: {"answer": "You asked: hi"} OK ``` (I used port 8123 for my test, passing it as the argument. Exit code 0.) ### Part 3: the Docker files Create `Dockerfile` next to `server.py`: ```dockerfile FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 \ HOST=0.0.0.0 \ PORT=8000 WORKDIR /app COPY server.py . RUN useradd --create-home appuser USER appuser EXPOSE 8000 CMD ["python", "server.py"] ``` Our server uses only the standard library, so there's no `requirements.txt` step. (When you add dependencies, use the layer-caching pattern from earlier.) The `ENV` line sets `HOST=0.0.0.0`, which solves gotcha 2, and `PYTHONUNBUFFERED=1`, which solves gotcha 3. `EXPOSE` is documentation: it records which port the app uses, but doesn't publish it. Publishing happens with `-p`. And a `.dockerignore`: ```markdown .git .venv __pycache__/ *.pyc .env *.db ``` Now build it, run it, and test it with the same script: ```bash docker build -t chat-api . docker run --rm -p 8000:8000 --name chat-api chat-api ``` In a second terminal: ```bash ./scripts/smoke_test.sh http://localhost:8000 ``` If it prints `OK`, your API is running in a container, and any machine with Docker can run it the same way. A few more things to try: `docker ps` shows it running, `docker logs chat-api` shows the `Listening on http://0.0.0.0:8000` line, and `docker exec -it chat-api bash` drops you inside. Stop it with `Ctrl+C`, or `docker stop chat-api` if you started it with `-d`. ### Make it yours 1. Remove the `HOST=0.0.0.0` line from the Dockerfile, rebuild, and see exactly what failure you get. Then put it back. 2. Add a `HEALTHCHECK` instruction to the Dockerfile, so Docker can tell when the app is healthy. Look up the syntax. 3. Write a `compose.yaml` that runs your API on port 8000, and run it with `docker compose up`. 4. Change the server to store its request count in a SQLite file (Post 21), and keep it in a volume so it survives restarts. 5. Write a `Makefile` or a shell script with shortcuts: `build`, `run`, `test`. 6. Add the smoke test to a GitHub Actions workflow once you reach Post 28. ## Practice About 20 minutes of real effort on each before asking for help. 1. Using only `find`, `grep`, and `wc`, count how many Python files in a project contain the word `import`. 2. Write a pipeline that prints the five most common words in any text file. Then make it ignore case. 3. Create a script that isn't executable, run it to see the error, then fix it with `chmod`. What numeric permissions does it end up with? 4. Run a long `sleep` in the background, find it with `ps`, and stop it with `kill`. What exit code does it give? 5. Write a shell script that takes a folder name as an argument, and prints an error and exits with code 1 if it doesn't exist. Use `set -euo pipefail`, and quote everything. 6. Break a command on purpose by using an unquoted variable with a space in it, then fix it. 7. Redirect a script's output and errors into two separate files, then into one file, then both to the screen and a file with `tee`. 8. Write a Dockerfile for any small Python script of yours. Explain why each line is where it is. 9. Explain in your own words why a server inside a container must listen on `0.0.0.0`. Then ask an AI tutor to review your answers, and don't let it rewrite them. ## What to take away - The terminal is the window, and the shell is the program inside it. On Windows, use WSL. - `ls`, `cd`, `pwd`, `cp`, `mv`, `rm`, `grep`, `find`, `head`, and `tail -f` cover most daily work. `rm` has no undo. - Small tools plus pipes beat one big tool: `sort | uniq -c | sort -rn` counts almost anything. - Permissions are `rwx` for owner, group, and others. Make scripts runnable with `chmod +x`, and keep secret files at `600`. - Never run a long job in a plain SSH session. Use `tmux` or `nohup`. - `export` makes variables visible to child programs, `PATH` decides what commands exist, and exit codes (0 means success) glue everything together. - Start scripts with `set -euo pipefail`, and **always quote your variables**. - A Docker image is a frozen environment, and a container is a running instance. Copy requirements first to use the layer cache. - Containers forget, so use volumes. Servers inside them must listen on `0.0.0.0`. Python needs `PYTHONUNBUFFERED=1`. And secrets go in at run time, never into an image. * * * **Previous:** Post 22, _Git & GitHub Workflows_ **Next in the series:** Post 24, _Clean Code & Design Patterns_, where we focus on readability, refactoring, and writing code that other people (and future you) can understand.