6.5 Compose: a stack described in a file
The docker run command in lesson 6.4 was already getting long, and it
only had three options. Real services need volumes, environment
variables, restart policies, and often several containers that have to
find each other. Nobody remembers that as a command, and a command isn't
something you can put in Git.
Docker Compose is that command written down as a file. One .yaml
describing what should be running, and two words to make it so.
Where data lives: volumes
First, the question lesson 6.4 ended on.
A container's filesystem dies with it. To keep anything, you map a directory on the host into the container, so writes land on UBNT01's disk instead of inside the disposable box. That mapping is a volume, and it's the line most likely to matter when you're reading somebody else's compose file: it tells you where the real data is.
The rule of thumb: containers are disposable, volumes are not. Back up the volumes, and you can throw the containers away as often as you like. Get that backwards and you will eventually delete something you wanted.
A place for stacks
Give yourself a predictable layout, for the same reason lesson 3.3 gave every VM its own folder:
# One directory per stack, all in one place.
mkdir -p ~/docker/whoami
cd ~/docker/whoami
Write one
nano compose.yaml
services:
whoami:
image: traefik/whoami
container_name: whoami
restart: unless-stopped
ports:
- "127.0.0.1:8081:80"
Read it as four answers. image: what to run. container_name: what
to call it, instead of a random name. restart: unless-stopped: bring
it back after a reboot or a crash, unless I stopped it deliberately,
which is what you want for anything that matters. ports: publish it,
and note the 127.0.0.1: prefix, which is doing real work.
That prefix means the service listens only on the server itself, not
on the network. Compare it to lesson 6.4, where -p 8080:80 exposed
nginx to your whole LAN. From here on, services in this lab are published
to loopback and reached through a reverse proxy, which lesson 6.7 builds.
One front door, not a scattering of open ports, is how you keep track of
what a machine actually exposes.
Run it
# Read the file, pull what's missing, start everything. -d detaches.
docker compose up -d
# What did that create?
docker compose ps
# Prove it's listening on loopback only.
curl http://127.0.0.1:8081
That last command prints the container's view of the request. Now try it
from your own computer at http://10.10.10.20:8081, and watch it fail:
the port is bound to loopback, so nothing outside the server can reach
it. The failure is the lesson.
The other verbs, which are all you need:
docker compose logs -f # follow the logs, Ctrl+C to stop
docker compose restart # bounce it
docker compose pull # fetch newer images
docker compose down # stop and remove the containers
down removes containers but leaves volumes alone, which is exactly the
disposable-containers, durable-data split from earlier in this lesson.
Why this file is the point
You now have a text file that fully describes a running service. Put it in Git and you have the service's history: what changed, when, and why. Copy it to another machine and you get the same service. Hand it to a colleague and they can read what you deployed without logging into anything.
That's infrastructure as code, in its smallest useful form, and it's the same idea Module 10 scales up with Ansible. Which is a good reason to start now:
This is a different machine, so Git does not know you here. In lesson
1.3 you told Git your name and email on your laptop. That setting lives in
your home directory, not in Git itself, and UBNT01 has never met you. Skip
this and git commit refuses with "Author identity unknown":
# Same two values you used in lesson 1.3. Every commit is stamped
# with them, which is how history says who did what.
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
# And the branch-name setting from 1.3, for the same reason: this
# server would otherwise create branches called "master" while
# lesson 6.8 pushes "main", and the error you would get names
# neither cause.
git config --global init.defaultBranch main
# Your stacks belong in version control from the first one.
cd ~/docker
# -b main names the first branch explicitly. The setting above would
# do it anyway; writing it here means this block works even if you
# skipped a line.
git init -b main
Write the ignore rules before the first commit, not after. This is the
half of .gitignore that lesson 1.3 could not show you, because the vault
already had one.
# A file named .env is the standard place a compose stack keeps its
# passwords. Git will now refuse to see any of them, at any depth,
# because a pattern with no slash in it matches in every folder.
cat > .gitignore <<'EOF'
# Secrets. Compose reads .env automatically, so this is where
# passwords end up. They must never reach a Git server.
.env
# Data directories that containers write into. Lesson 6.6 gives Gitea
# a ./data holding its database, and lesson 7.4 gives step-ca one
# holding a private key. Those are state and secrets, not configuration.
*/data/
EOF
git add .gitignore whoami/compose.yaml
git commit -m "whoami: first compose stack, with ignore rules"
You do not have a single .env file yet. That is precisely why you are
writing this rule now. A .gitignore only stops files Git has never been
told about. Anything already in a commit stays tracked forever, so adding a
secret to .gitignore after you committed it changes nothing at all, which is
the most common misunderstanding about this file in the whole of Git. If you ever
land in that situation, git rm --cached .env stops the tracking while
leaving the file on disk.
Being honest about the limit of that: git rm --cached removes the file from
the next commit, not from the history behind it, and not from any server you
already pushed to. A secret that has been pushed has to be treated as leaked,
and the fix is to change the password rather than to rewrite history. In a
lab that costs you two minutes. In a job it costs considerably more, which is
why the rule goes in first.
Module 8 builds Keycloak, whose .env holds an admin password and a database
password, and it lands in this repository. You will not have to remember any
of this when it does.
How you know it worked:
# One commit, by you, on a branch called main. That last part
# matters in 6.8, so check it now rather than debugging a push.
git log --oneline --decorate
Expect a single line ending in your commit message, with (HEAD -> main)
in it. If it says master instead of main, the repository was created
before the config took. Rename the branch, which is safe and instant:
git branch -M main
Leave it local for now. In lesson 6.8 it gets a remote, on a Git server you're about to build.
Tidy up before moving on:
cd ~/docker/whoami && docker compose down