Containers Crash Course uses impress.js (CSS 3D transforms). Open it in a modern desktop browser.
By the end: you'll have built, run, and curl-tested a real containerized API, and you'll know why Podman is the safer sibling worth meeting next.
Different OS. Different Python version. A dependency installed years ago that nobody wrote down. A missing env var. A port already in use.
Containers end that conversation. Your app ships with its whole little world around it.
Teasers for all of these at the end. Worth the self-study after this workshop.
That's the surface win. The deeper reason they exist: isolation. Shrink the blast radius when something goes wrong.
Yes, Docker runs on Windows and Mac too. We use Linux (via WSL2) because that's what real cloud servers run, and the muscle memory transfers.
uname -aIf the Store fails: open PowerShell as Admin → wsl --install -d Ubuntu-24.04 → reboot if asked.
pwd # where am I? ls -la # list files (long, show hidden) cd ~/projects # change directory whoami # which user am I? cat file.txt # print a file to the screen mkdir myapp # make a new folder nano notes.md # simple text editor (Ctrl-O save, Ctrl-X exit)
Docker Desktop needs a paid license for larger companies. Engine-only is free, same CLI.
# 1. Add Docker's official apt repo curl -fsSL https://get.docker.com | sudo sh # 2. Let your user run docker without sudo sudo usermod -aG docker $USER newgrp docker # 3. Verify docker --version docker run hello-world
docker run -d -p 8080:80 --name web nginx
-d: run detached (in the background)-p 8080:80: publish host port 8080 to container port 80--name web: a friendly name you can reference laterNow open http://localhost:8080 in your browser. You're running nginx, in Linux, in a container, and never typed apt install nginx.
docker ps # running containers docker ps -a # including stopped docker logs web # what's it saying? docker logs -f web # follow logs (Ctrl-C to stop) docker exec -it web bash # a shell INSIDE the container docker stop web # graceful stop docker rm web # remove it docker images # local images you've pulled/built docker pull python:3.12-slim docker rmi hello-world # remove an image docker system prune # clean up unused stuff
Eight dishes, six spots. Tags mean what they say, so the browser renders it readable even with zero styling.
<!-- cebu-eats/site/index.html --> <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Cebu Eats & Spots</title> </head> <body> <h1>Cebu Eats & Spots</h1> <p>Eight dishes you must try. Six places you must see.</p> <h2>Eats</h2> <dl> <dt><b>Lechon</b> <i>(Carcar / Talisay)</i></dt> <dd>Spit-roasted suckling pig. Crackling skin, lemongrass-stuffed belly, no sauce needed.</dd> <dt><b>Tuslob Buwa</b> <i>(Pasil, Cebu City)</i></dt> <dd>Pig brain and liver simmered into a frothy dip. Eaten by hand with puso, elbow-to-elbow at a street stall.</dd> <dt><b>Puso</b> <i>(all over Cebu)</i></dt> <dd>Hanging rice woven inside coconut leaves. The default pairing for lechon, bbq, and tuslob buwa.</dd> <dt><b>Humba</b> <i>(Cebuano household)</i></dt> <dd>Pork belly slow-braised in soy, vinegar, brown sugar, and tausi. Deep, sweet, savory.</dd> <dt><b>Ngohiong</b> <i>(Chinese-Cebuano)</i></dt> <dd>Five-spice spring roll, deep-fried, served with sweet-and-sour sauce. Street-food royalty.</dd> <dt><b>SuTuKil</b> <i>(Mactan seaside)</i></dt> <dd>Sugba, Tuwa, Kilaw: grilled, in soup, raw-cured. Pick your fish, pick all three preparations.</dd> <dt><b>Sinuglaw</b> <i>(Cebuano fusion)</i></dt> <dd>Grilled pork belly meets kinilaw. Smoky sugba on top of vinegar-cured raw fish. Two worlds in one bowl.</dd> <dt><b>Siomai sa Tisa</b> <i>(Tisa, Cebu City)</i></dt> <dd>Pork-and-shrimp dumplings steamed in bamboo, dunked in chili-vinegar-soy. An entire alley of stalls competes for your pesos.</dd> </dl> <h2>Spots</h2> <dl> <dt><b>Magellan's Cross</b> <i>(Cebu City)</i></dt> <dd>Planted in 1521 by Magellan's crew. The symbolic start of Christianity in the Philippines.</dd> <dt><b>Basilica del Santo Niño</b> <i>(Cebu City)</i></dt> <dd>The country's oldest Roman Catholic church, founded in 1565. Home of the revered Santo Niño image.</dd> <dt><b>Kawasan Falls</b> <i>(Badian)</i></dt> <dd>Turquoise three-tiered waterfall at the end of a short jungle hike. The headline canyoneering destination.</dd> <dt><b>Oslob Whale Sharks</b> <i>(Oslob)</i></dt> <dd>Snorkel beside wild butanding at dawn. Go early, go ethical.</dd> <dt><b>Moalboal Sardine Run</b> <i>(Moalboal)</i></dt> <dd>Millions of sardines moving as one silver cloud, five meters off the shore of Panagsama Beach.</dd> <dt><b>Tops Lookout</b> <i>(Busay hills)</i></dt> <dd>The city laid out below at sunset. Bring a jacket, the wind is honest.</dd> </dl> <hr> <p><small>Static page, served by nginx in one container. Workshop sample for <b>Containers Crash Course</b>.</small></p> </body> </html>
Base image, copy your files in, declare the port. That's the whole recipe for a static site.
# cebu-eats/Dockerfile FROM nginx:alpine COPY site /usr/share/nginx/html EXPOSE 80
That's it. Three real lines. The whole site lives in cebu-eats/site/ and nginx serves it.
# in cebu-eats/ docker build -t cebu-eats . docker run -d -p 127.0.0.1:8080:80 \ --name eats cebu-eats open http://localhost:8080
Image is small (around 90 MB, almost all nginx). You get one page, two sections: Eats (lechon, tuslob buwa, sinuglaw, siomai sa Tisa, and more) and Spots (Kawasan, Oslob, Tops, and more). No JS, no CSS, pure semantic HTML.
A FastAPI CRUD service with a plain-JS frontend. Post a pickleball game, list open games, delete yours. One container, but now there's a backend talking.
| Endpoint | Does |
|---|---|
POST /matches | create a game listing |
GET /matches | list all games |
GET /matches/{id} | one game |
PUT /matches/{id} | update it |
DELETE /matches/{id} | remove it |
Code lives in app/ next to this deck. Clone it and follow along.
FROM python:3.12-slim # small, pinned base ENV PYTHONUNBUFFERED=1 # print logs immediately WORKDIR /app # working dir inside the container COPY requirements.txt ./ # deps first (cache-friendly) RUN pip install -r requirements.txt COPY main.py ./ # then the app COPY static ./static RUN useradd app && chown -R app /app USER app # NOT root. critical. EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# inside the app/ folder docker build -t pickle-api . # run it, publish to localhost only (safer than 0.0.0.0!) docker run -d \ -p 127.0.0.1:8000:8000 \ -v $(pwd)/data:/app/data \ --name pickle pickle-api # visit it open http://localhost:8000
-v $(pwd)/data:/app/data is a volume. Data survives container restart because it lives on your laptop's disk.
# install curl first (Debian/Ubuntu/WSL2 usually has it, run this if not) sudo apt install -y curl curl --version # CREATE curl -X POST http://localhost:8000/matches \ -H "Content-Type: application/json" \ -d '{"venue":"Ayala Terraces Court 2","date":"2026-10-20 19:00", "skill_level":"beginner","players_needed":2,"organizer_name":"Ron"}' # LIST curl http://localhost:8000/matches # GET ONE / UPDATE / DELETE (ID from the create response) curl http://localhost:8000/matches/<id> curl -X PUT http://localhost:8000/matches/<id> -H "Content-Type: application/json" -d '{...}' curl -X DELETE http://localhost:8000/matches/<id>
Full script: curl-tests.sh in the deck repo.
-v /host/path:/container/path mounts a folder from your laptop inside the container.docker rm -f pickle docker run -d -p 127.0.0.1:8000:8000 \ -v $(pwd)/data:/app/data \ --name pickle pickle-api curl http://localhost:8000/matches # your matches are still there
python:3.12-slim not python:latest.dockerignore (skip .git, __pycache__, .env):latest. Silent breaking changes.COPY . . at the top. Every code change busts the cache.Badly configured containers leak just like anything else. The big ones:
-p 8000:8000 binds 0.0.0.0 by default → your whole LAN can reach it. Use -p 127.0.0.1:8000:8000 unless you mean to publish./var/run/docker.sock → that container can now control Docker on the host. Full escape.--privileged or --network host → you threw away the isolation you came here for.docker pulls it reads them.Isolation is the whole point. Don't disable it to make a tutorial easier.
In the AI era, your tool stack is read by three audiences. Keep all three happy.
| Audience | Likes | Hates |
|---|---|---|
| The machine | low CPU, low RAM, small images | bloat, slow startup |
| Humans | readable, simple, well-named | cleverness, deep inheritance |
| The LLM | small codebases that fit in context | sprawling repos it can't hold |
Starter Python/Node is fine. For a leaner next step: Go (tiny binaries, fast startup) and Rust (safer, no runtime). Both drop container image sizes dramatically and give Claude/Cursor less to chew on.
Five on-ramps. Each is one evening of study.
One YAML file describes all your services. One command brings the stack up.
# docker-compose.yml services: api: build: ./backend ports: ["8000:8000"] db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: devpass
docker compose up -d docker compose logs -f docker compose down
docker ... commands work as podman ....sudo apt install -y podman alias docker=podman podman run -d -p 127.0.0.1:8000:8000 pickle-api
Lives on the host, not in a container. That's the point, and the reason we taught Linux.
sudo apt install -y nginx # /etc/nginx/sites-available/pickle upstream pickle_backend { server 127.0.0.1:8001; server 127.0.0.1:8002; server 127.0.0.1:8003; } server { listen 80; location / { proxy_pass http://pickle_backend; } } sudo nginx -t && sudo systemctl reload nginx
sudo apt install -y fail2ban # /etc/fail2ban/jail.local [sshd] enabled = true maxretry = 5 [nginx-botsearch] enabled = true logpath = /var/log/nginx/access.log sudo systemctl enable --now fail2ban sudo fail2ban-client status sshd
Add nginx rate-limiting with limit_req_zone for the belt-and-suspenders combo.
A short detour for the marketers, students, and vibe-coders. nginx is more than a hello-world. Your cebu-eats container is a perfect lab for SEO and tag-manager drills.
try_files. SPA fallback, old-school multi-page, both.Every large site you've visited has nginx or a sibling (Caddy, HAProxy, Envoy) somewhere in the stack.
# install (Debian / Ubuntu / WSL2) sudo apt update sudo apt install -y nginx sudo systemctl enable --now nginx curl -I http://localhost # should return "Server: nginx/..." # /etc/nginx/sites-available/cebu-eats server { listen 80; server_name cebu-eats.local; root /usr/share/nginx/html; gzip on; gzip_types text/html text/css application/javascript; location / { try_files $uri $uri/ /index.html; add_header Cache-Control "public, max-age=3600"; } }
sudo nginx -t to validate, sudo systemctl reload nginx to apply with zero downtime.
cebu-eats/site/index.html, rebuild, refresh. Change goes live in seconds.curl -I http://localhost:8081/ to see your response headers like Googlebot does.npm install -g lighthouse then lighthouse http://localhost:8081 for a real score.Static HTML in a container is the cleanest SEO practice lab you can get. No framework in the way.
<!-- inside <head>, before anything else --> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>Cebu Eats & Spots - 8 Dishes, 6 Places</title> <meta name="description" content="A hand-picked guide to the dishes you must try in Cebu, and the spots you must see."> <link rel="canonical" href="https://cebu-eats.example.com/"> <!-- Open Graph: Facebook, LinkedIn, Slack previews --> <meta property="og:title" content="Cebu Eats & Spots"> <meta property="og:description" content="Lechon, Sinuglaw, Kawasan Falls, and more."> <meta property="og:image" content="https://cebu-eats.example.com/og.jpg">
title and description land in Google results. og: tags land in every social share preview.
<script type="application/ld+json"> { "@context": "https://schema.org", "@graph": [ { "@type": "Recipe", "name": "Lechon", "recipeCuisine": "Cebuano", "description": "Spit-roasted suckling pig from Carcar, Cebu." }, { "@type": "TouristAttraction", "name": "Kawasan Falls", "address": "Badian, Cebu, Philippines", "description": "Turquoise three-tiered waterfall." } ] } </script>
Paste into <head>. Google renders rich results (recipe card, place card) from this block. Test at search.google.com/test/rich-results.
<!-- in <head>, as high as possible --> <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl; f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-XXXXXXX');</script> <!-- right after <body>, fallback for no-JS --> <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
Replace GTM-XXXXXXX with your container ID from tagmanager.google.com. Your marketing team can now wire new trackers without ever touching your code again.
# both containers, same 3-line Dockerfile, different site/ cebu-eats/ cebu-eats-seo/ Dockerfile Dockerfile site/ site/ index.html index.html # + SEO head, JSON-LD, GTM robots.txt sitemap.xml # run them side by side docker build -t cebu-eats cebu-eats/ docker build -t cebu-eats-seo cebu-eats-seo/ docker run -d -p 127.0.0.1:8081:80 --name eats cebu-eats docker run -d -p 127.0.0.1:8082:80 --name eats-seo cebu-eats-seo # diff just the HTML to see what SEO costs you diff cebu-eats/site/index.html cebu-eats-seo/site/index.html
Both pages look the same to a human. To Google, LinkedIn, and your analytics dashboard, the SEO one is a different universe. Images weigh in at ~94 MB each (same nginx base, HTML delta is a rounding error).
docker push yourname/pickle-api.