{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"linux-shell-docker-the-daily-toolkit-0w70o","url":"https://zyvop.com/linux-shell-docker-the-daily-toolkit-0w70o","title":"Linux, Shell & Docker: The Daily Toolkit","subtitle":"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.","tldr":"Most AI work happens in a terminal on Linux, often inside containers. This post covers navigating, searching logs, permissions, processes, shell scripts, SSH, and Docker, including the three gotchas that trip everyone up, then containerizes the API from Post 20.","keywords":["Docker","linux","AI Engineer","terminal","shell","AI","Zero to Top AI Engineer (2026): Full Series Plan"],"entities":["Pradeep Kumar","Docker","linux","AI Engineer","terminal","shell","AI","Zero to Top AI Engineer (2026): Full Series Plan","ZyVOP"],"keyTakeaways":["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."],"headings":["The terminal, the shell, and why bother","Finding your way around","Working with files","Searching and slicing text","grep: find lines that match","find: locate files","sort, uniq, cut, tr, awk, sed","Permissions","Processes and long-running jobs","Background jobs","The problem with long jobs and SSH","Exit codes, again","Environment variables and PATH","Redirection and pipes","Shell scripts","The first line that matters: set -euo pipefail","Always quote your variables","Shell or Python?","SSH: working on other machines","Docker: packaging your app","The ideas","The commands you'll use","A Dockerfile","Three gotchas that catch everyone"],"outboundLinks":[],"contentText":"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: 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 wasSome 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): 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 testsThe 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 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 itA 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: 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: 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: 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 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 foldergrep -r is how you find where something is used across a whole project. find: locate files 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 daysort, 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: awk '{print $(NF-1)}' server.log | sort | uniq -c | sort -rn5 200 2 429 1 422 1 404awk 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: awk '{print $7}' server.log | sort | uniq -c | sort -rn | head -3A few more small tools: 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 lineAnd the word counter from Post 5, in one line of shell: printf \"the cat sat on the mat\\nthe dog sat on the log\\n\" | tr ' ' '\\n' | sort | uniq -c | sort -rn | head -34 the 2 sat 2 onIf 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: -rwxr-xr-x 1 asha asha 47 Nov 2 10:08 hello.shRead 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: printf '#!/usr/bin/env bash\\necho \"hello from a script\"\\n' &gt; hello.sh ./hello.shbash: ./hello.sh: Permission deniedchmod +x hello.sh ./hello.shhello 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: 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 &amp; to a command runs it in the background so you get your prompt back: sleep 30 &amp; jobs # list background jobs kill %1 # stop job number 1The 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: nohup python train.py &gt; train.log 2&gt;&amp;1 &amp; # keep running after I disconnector, better, use tmux, a tool that keeps a terminal session alive on the server: 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 $?: true; echo $? # 0 false; echo $? # 1 ls /does/not/exist 2&gt;/dev/null; echo $? # 2When 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 &amp;&amp; and || use exit codes to chain commands: grep -q \"line one\" sample.txt &amp;&amp; echo \"found it\" grep -q \"nothing\" sample.txt || echo \"not found\"&amp;&amp; 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: 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_NAMEexport 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: MODEL_NAME=oneoff bash -c 'echo \"$MODEL_NAME\"' # oneoffAfter 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: set -a; source .env; set +aOne 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: echo \"$PATH\" | tr ':' '\\n' which python3 # shows which one the shell will runIf 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 &gt; Send stdout to a file (overwrites) &gt;&gt; Send stdout to a file (appends) 2&gt; Send stderr to a file 2&gt;&amp;1 Send stderr to the same place as stdout &lt; 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: #!/usr/bin/env bash echo \"normal output\" echo \"an error message\" &gt;&amp;2./noisy.sh &gt; out.txt 2&gt; err.txt # separate files: out.txt has the normal line, err.txt the error ./noisy.sh &gt; both.txt 2&gt;&amp;1 # one file with both ./noisy.sh 2&gt;/dev/null # hide the errors (/dev/null is a black hole) ./noisy.sh 2&gt;&amp;1 | tee run.log # watch it live AND save itThe last one, 2&gt;&amp;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: #!/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\" fiRun 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 -c 'false; echo \"still running after a failure\"' # still running after a failureset -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: 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.txtWithout 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: 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: 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 laptopThe 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: Host gpu HostName gpu-box.example.com User asha IdentityFile ~/.ssh/id_ed25519After 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: 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: 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: via 192.0.2.2: http 000 &lt;- connection failedThen I bound it to 0.0.0.0 and tried again: via localhost: http 200 via 192.0.2.2: http 200When 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: 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: docker run --rm --gpus all some-gpu-image nvidia-smiYou'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: 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: 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: HOST=localhost PORT=8123 python server.pyPart 2: a smoke test in shell A smoke test is a quick check that the basics work. Create scripts/smoke_test.sh: #!/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\" &gt; /dev/null 2&gt;&amp;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\" &gt;&amp;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: 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 000and the exit code is 1. Then start the server in one terminal and run the script in another: 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: 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: .git .venv __pycache__/ *.pyc .env *.dbNow build it, run it, and test it with the same script: docker build -t chat-api . docker run --rm -p 8000:8000 --name chat-api chat-apiIn a second terminal: ./scripts/smoke_test.sh http://localhost:8000If 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 Remove the HOST=0.0.0.0 line from the Dockerfile, rebuild, and see exactly what failure you get. Then put it back. Add a HEALTHCHECK instruction to the Dockerfile, so Docker can tell when the app is healthy. Look up the syntax. Write a compose.yaml that runs your API on port 8000, and run it with docker compose up. Change the server to store its request count in a SQLite file (Post 21), and keep it in a volume so it survives restarts. Write a Makefile or a shell script with shortcuts: build, run, test. 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. Using only find, grep, and wc, count how many Python files in a project contain the word import. Write a pipeline that prints the five most common words in any text file. Then make it ignore case. 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? Run a long sleep in the background, find it with ps, and stop it with kill. What exit code does it give? 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. Break a command on purpose by using an unquoted variable with a space in it, then fix it. 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. Write a Dockerfile for any small Python script of yours. Explain why each line is where it is. 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 &amp; GitHub Workflows Next in the series: Post 24, Clean Code &amp; Design Patterns, where we focus on readability, refactoring, and writing code that other people (and future you) can understand.","contentHash":"sha256:c3280355fe531a562209914a157eaa4a3c7c85bce636dff41ac13277c8bf47af","authorName":"Pradeep Kumar","authorUrl":"https://zyvop.com/author/pradeep","authorSameAs":[],"category":"AI","tags":["Docker","linux","AI Engineer","terminal","shell"],"audience":"Software engineers and developers building applications with AI","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":23,"wordCount":6058,"faqs":null,"primaryTopic":"AI","publishedAt":"2026-11-02T10:30:00.069Z","updatedAt":"2026-11-02T10:17:35.164Z","canonicalUrl":"https://zyvop.com/linux-shell-docker-the-daily-toolkit-0w70o"}