Linux DevOps · Lesson 1 · 30 min

Containers: build & ship your own image

In the Alpine lesson you ran an image someone else made. Now you'll make your own. A container image packs an app together with everything it needs: the right libraries, the right versions, the right settings. The same image runs on your laptop, on a test server and in production, on Rocky and on Ubuntu. That's a big part of what DevOps is: you ship a box that runs the same way everywhere, instead of a list of setup steps someone has to follow.

You will learn

  • Reading and writing a Containerfile (a Dockerfile under a different name)
  • podman build: images, tags and the layer cache
  • Publishing a port with -p, and why podman won't give you port 80
  • Container life: ps, logs, stop, rm, exec
  • The rebuild loop: change, build, replace, test

Why DevOps teams ship images

Every developer has heard it: “but it works on my machine!” The app needed Python 3.12 and the server had 3.9, or a library was missing, or a config file was in a different place. An image fixes that, because the app's machine comes along with it.

The recipe: a Containerfile

In the practice terminal, ~/hello-web holds a one-page website and its recipe:

# hello-web: a tiny website in a container
FROM docker.io/library/alpine:3.22

# busybox-extras gives us a small web server called httpd
RUN apk add --no-cache busybox-extras

# put our page where the web server will look for it
COPY index.html /www/index.html

# document which port the app listens on
EXPOSE 8080

# -f = stay in the foreground, -v = log every request, -p = port, -h = folder to serve
CMD ["httpd", "-f", "-v", "-p", "8080", "-h", "/www"]
InstructionWhat it does
FROMThe image to start from. Always the first line. Use a version tag like 3.22, not latest, so the build is the same next month.
RUNRuns a command while building, usually to install packages. This base is Alpine, so it uses apk, not dnf or apt.
COPYCopies files from your folder (the build context) into the image.
WORKDIRSets the folder that later commands run in (like cd).
ENVSets an environment variable inside the image.
EXPOSERecords which port the app uses. It's a note for humans: it doesn't open anything by itself.
CMDThe command a container runs when it starts.
Containerfile or Dockerfile?

They're the same format. podman looks for Containerfile first, then Dockerfile. Docker looks for Dockerfile, so with Docker it's safest to name the file Dockerfile or pass -f Containerfile.

The foreground rule

A container lives only as long as its main process. Many servers “daemonize” (fork into the background and exit), which is right on a normal server and wrong in a container: the main process exits, so the container stops at once. That's why the recipe uses httpd -f (foreground). You'll see the same idea as nginx -g 'daemon off;' and httpd -DFOREGROUND.

Build it

Rocky / RHEL
sudo dnf install podman
cd ~/hello-web
podman build -t hello-web .

No sudo for podman. You build and own the image as your normal user.

Ubuntu / Debian
sudo apt install podman
cd ~/hello-web
podman build -t hello-web .
# or with Docker:
sudo docker build -t hello-web .

Ubuntu's docker.io package prints a note that its “legacy builder” is deprecated. Installing docker-buildx switches to the newer BuildKit builder.

-t hello-web names (tags) the image. The dot at the end is the build context, “this folder”: COPY can only see files inside it. Forgetting the dot is the most common build mistake.

STEP 1/5: FROM docker.io/library/alpine:3.22
STEP 2/5: RUN apk add --no-cache busybox-extras
(1/1) Installing busybox-extras (1.37.0-r18)
--> 326d07856973
STEP 3/5: COPY index.html /www/index.html
--> 01fb22933911
…
COMMIT hello-web
Successfully tagged localhost/hello-web:latest

Each --> 326d07856973 is a layer: a saved snapshot of the image after that step. Your image is named localhost/hello-web:latest. localhost/ means it was built here and not downloaded, and latest is the tag you get when you don't pick one.

The layer cache

Build again without changing anything, and every step says Using cache: podman reuses the saved layers and finishes almost instantly. Change index.html, and only the COPY step and the steps after it run again. That's why recipes put slow, rarely-changing steps (installing packages) near the top and your own files near the bottom.

Run it, and publish a port

podman run -d --name web -p 8080:8080 hello-web
curl localhost:8080

Containers have their own private network. Without -p, the site runs, but nothing outside the container can reach it.

Ports below 1024

Ask rootless podman for -p 80:8080 and it refuses: only root may open ports below 1024. Use a high port (and a reverse proxy or load balancer in front of it later), or run the container as root. Docker's daemon runs as root, so it can take port 80. That's also why using Docker needs sudo.

To reach the site from another computer, also open the port in the firewall:

Rocky / RHEL
sudo firewall-cmd --add-port=8080/tcp --permanent
sudo firewall-cmd --reload
Ubuntu / Debian
sudo ufw allow 8080/tcp

Watch out: Docker writes its own firewall rules, which can skip past ufw, so a published Docker port may be reachable even when ufw says it's blocked.

Living with containers

podman on both families (swap in sudo docker for Docker)
podman ps                    # running containers
podman ps -a                 # all of them, stopped ones too
podman logs web              # what the app printed (add -f to follow)
podman exec -it web sh       # a shell inside the running container
podman stop web              # stop it (it still exists)
podman start web             # start it again
podman rm web                # delete the container
podman images                # images you have
podman rmi hello-web:v2      # delete an image

Apps in containers write logs to the screen (stdout and stderr), not to files in /var/log. podman logs collects them for you.

The rebuild loop

You could exec into the container and edit /www/index.html. Don't. That change lives only in that one container. It disappears when the container is replaced, and nobody else can see it or review it. Change the source, build a new image and swap containers:

sed -i 's/Version 1/Version 2/' index.html     # 1. change the source
podman build -t hello-web:v2 .                # 2. build a new, clearly named image
podman stop web && podman rm web               # 3. retire the old container
podman run -d --name web -p 8080:8080 hello-web:v2
curl localhost:8080                           # 4. test it

The first image, hello-web:latest, is still there, so rolling back is just running it again. That's why real teams tag every build with a version (or the git commit) instead of reusing latest. Later in this path, a CI pipeline will do steps 2 to 4 for you on every git push.

Shipping: registries

So far the image lives only on this server. To share it, you push it to a registry, a server that stores images: Docker Hub (docker.io), Quay (quay.io), GitHub's ghcr.io, or a cloud one like AWS ECR. Every other server then pulls the exact same image.

podman login quay.io
podman tag hello-web:v2 quay.io/YOURNAME/hello-web:v2
podman push quay.io/YOURNAME/hello-web:v2

(The practice terminal is offline, so there's no pushing there. The commands above are what you'd run for real.)

Practice: build, ship, rebuild 📦

Build hello-web, run it, check its logs, then ship a version 2 the right way.

Quick check

1. podman build -t hello-web fails straight away. What's missing?

2. You ran podman run -d -p 8080:80 hello-web. The container is running, but curl says Connection reset by peer. Why?

3. You changed only index.html and rebuilt. Why did the RUN apk add step say Using cache?

4. A teammate fixed a typo by running podman exec -it web vi /www/index.html. What's the problem?

Finished the missions and the quiz? Mark it done to track your progress.