Package the app.
Keep the server.
Run a small application inside a container on your existing Ubuntu VM. Learn image selection, port mapping, resource limits and repeatable updates—without adding a managed cluster.
These are sample names, not our infrastructure. Before copying commands, read Customize the examples: paths, keys, IPs and names. Substitute your own authorized account and resources. Existing lab? Retain its original resource names, usernames and state; renamed samples can cause replacement. See the existing-lab warning before applying updated recipes.
Reuse one disposable Ubuntu server
Finish one Terraform/Ansible VM lab and the networking lesson first. Verify SSH, keep its current source-IP /32 allowance, and remove the direct HTTP allowance as described there. Windows users use Ubuntu/WSL on their laptop; Linux users use Ubuntu. The container itself runs on the cloud VM, not your laptop.
The existing host Nginx stays on port 80. Our container uses server loopback port 8081, so the original storage/server lessons remain intact. Terraform still owns the VM; it does not automatically install or manage this container. These website examples are not executed for you.
1. A lunchbox, not another computer
The VM is the kitchen. An image is a packed lunch recipe. A container is one running lunchbox made from that recipe. Compose is the list saying which lunchbox to run, what it can use, and where to find its ingredients.
| Piece | What it does | What it does not do |
|---|---|---|
| Image | Packages Python and its runtime files | Automatically update when a new tag is published |
| Container | Runs a process using the host kernel | Provide the same isolation boundary as a separate VM |
| Bind mount | Lets it read this host's public HTML directory | Back up that directory |
| Compose | Describes ports, limits, mounts and health checks | Create cloud networking or stop cloud billing |
| Registry | Stores downloadable images | Guarantee an image is safe because it has a familiar name |
This lab uses Python's built-in HTTP server to make the process easy to inspect. It is a learning server, not a production web server. Containers apply to APIs, workers and other programs too—not just websites.
2. Install Docker inside the cloud VM
SSH into the exact lab machine. Confirm cat /etc/os-release shows Ubuntu 24.04. Follow Docker's official Ubuntu apt-repository installation, including its prerequisite checks, repository signing key and Compose plugin. Review package conflicts before removal; do not uninstall another workload's runtime.
Use the repository method, not an unreviewed convenience script. This page links the maintained installer instructions rather than duplicating commands that may change. After installation, run:
sudo systemctl status docker --no-pager
sudo docker version
sudo docker compose version
df -h /Use a supported patched Docker Engine, at least version 28 for this lesson. Older engines had a localhost-port exposure issue. Stop if the server version is older; follow the vendor's upgrade instructions. Keep using sudo for Docker: membership in the Docker group gives powerful host access, not merely permission to run this one app.
Port publishing and localhost caveatDocker and firewallsDocker-group security implications
In every new laptop terminal, set LAB_SSH_KEY to your actual private-key file, not its .pub partner. For example, LAB_SSH_KEY="$HOME/.ssh/contoso_aws" works only if you created that AWS key there. Choose your Azure or Google key for those servers. See key paths on Windows, WSL and Linux; never copy a Windows path into WSL unchanged.
3. Pin the image and prepare the files
Inside the cloud VM, create a fresh, empty staging folder. If this path already contains your work, choose another folder rather than overwriting it:
mkdir ~/contoso-container
cd ~/contoso-container
mkdir site
sudo docker pull python:3.13-alpine
sudo docker image inspect python:3.13-alpine \
--format '{{index .RepoDigests 0}}'The tag is a moving label; the returned python@sha256:… is the exact downloaded image reference. Check the source is the Docker Official Image for Python and record that complete digest. It is an integrity/version reference, not a vulnerability scan or a promise of safety. If there is no digest, stop and investigate the registry/pull result.
Download the two files below to your laptop. In Ubuntu/WSL, transfer compose.yaml to the staging folder and index.html into its site/ subfolder using your verified key:
scp -i "${LAB_SSH_KEY:?Set LAB_SSH_KEY to your private-key path}" compose.yaml ubuntu@YOUR-SERVER-IP:~/contoso-container/
scp -i "${LAB_SSH_KEY:?Set LAB_SSH_KEY to your private-key path}" index.html ubuntu@YOUR-SERVER-IP:~/contoso-container/site/For Azure and Google, set LAB_SSH_KEY to that server's actual private key and use contoso@ only if you created the VM account with that sample username. Otherwise substitute your configured login username. Do not disable host verification to make the transfer work.
Back inside the VM, use nano .env to create one line, replacing the whole example with the exact digest you inspected:
LAB_IMAGE=python@sha256:REPLACE_WITH_THE_COMPLETE_64_CHARACTER_DIGESTThen protect the reference file and make only the public demo readable by the container user:
chmod 600 .env
chmod 755 site
chmod 644 site/index.html
sudo install -d -m 0755 /opt/contoso-container
sudo cp -R compose.yaml .env site /opt/contoso-container/
cd /opt/contoso-containerCheck that /opt/contoso-container does not contain a different application before copying. This becomes the canonical runtime folder; edits in the staging folder are not automatically reflected here. The Ansible section later manages this same folder.
Read the recipe before running it
- Port mapping: server
127.0.0.1:8081leads to container8080. Inside the container, the process listens on its own interfaces; that does not publish every host interface. - Read-only: the container cannot change its root filesystem or the mounted public page directory. The host still can.
- Non-root: the app uses UID/GID 10001, drops Linux capabilities, and cannot gain new privileges through exec. These controls do not make untrusted code harmless.
- Limits: 128 MiB RAM, 0.25 CPU and 64 processes limit this container—not your cloud bill. VM capacity must also support the OS, Nginx and Docker.
- Logs: local JSON log rotation limits per-container log files. Images, host logs and other disk usage are separate.
- Restart: no automatic restart. A healthcheck marks health; it does not itself repair or restart a failed container.
4. Start, wait, and inspect
sudo docker compose --project-name contoso-container config --quiet
sudo docker compose --project-name contoso-container up -d --wait --wait-timeout 60
sudo docker compose --project-name contoso-container ps
sudo docker compose --project-name contoso-container logs --tail 20 web
curl --max-time 5 -I http://127.0.0.1:8081/--wait checks this recipe's healthcheck before reporting success. A 200 response still does not prove every application feature works. Inspect the bindings with sudo docker compose --project-name contoso-container ps: expect loopback 127.0.0.1:8081, not 0.0.0.0:8081.
5. Reach it through the existing SSH door
On your laptop, open a separate Ubuntu/WSL terminal. Use local port 8082 to avoid the original networking exercise's port 8080:
ssh -N -o ExitOnForwardFailure=yes \
-L 127.0.0.1:8082:127.0.0.1:8081 \
-i "${LAB_SSH_KEY:?Set LAB_SSH_KEY to your private-key path}" ubuntu@YOUR-SERVER-IPFor Azure/Google substitute the same key/user values used for file transfer. Keep this terminal open. Visit http://127.0.0.1:8082/, or run curl --max-time 5 http://127.0.0.1:8082/ in a second laptop Ubuntu terminal.
HTTP on laptop loopback is not public HTTPS. SSH encrypts the remote leg. If Windows localhost forwarding is unavailable, use curl inside WSL; do not bind to all interfaces or open the cloud firewall as a workaround.
6. Update content, then practice recovery
Inside the VM, save a baseline outside the served directory before changing the canonical file:
mkdir ~/contoso-container-backup
cp -i /opt/contoso-container/site/index.html ~/contoso-container-backup/index.html
sha256sum ~/contoso-container-backup/index.html
sudo nano /opt/contoso-container/site/index.html
curl --max-time 5 http://127.0.0.1:8081/If the backup directory already exists, choose a fresh directory or preserve the existing copy; do not approve an overwrite of your only baseline. Change the version text to updated. Because the entire directory is mounted, host edits are visible without rebuilding the image. That is a content update, not an image upgrade. Do not put a spare backup inside site/, where this directory-serving demo could expose it.
Restore only after confirming this is the lab page. This overwrites your changed version:
sudo cp ~/contoso-container-backup/index.html /opt/contoso-container/site/index.html
sha256sum ~/contoso-container-backup/index.html /opt/contoso-container/site/index.html
curl --max-time 5 http://127.0.0.1:8081/The hashes should match and the baseline should return. This same-VM spare copy teaches file restoration; it is not protection against losing the VM. For meaningful data use an approved off-host backup design. The earlier backup lesson copies host Nginx's page, not this new container folder—do not assume it backs up both.
Image upgrades are a separate decision
Record the current digest. Pull the candidate tag, inspect its new digest, review changes/security findings, and replace LAB_IMAGE only deliberately. Run config validation and up -d --wait again. A changed image reference recreates the service. To roll back, restore the previous digest and run the same commands while that image remains available. This does not undo data migrations or guarantee the old image is safe.
7. Manage the same app with Ansible
Optional automation: on your laptop create a dedicated folder containing compose.yaml, deploy.yml, index.html.j2 and your one-host inventory.ini. Reuse the active Ansible virtual environment and SSH agent from the VM lesson. Docker/Compose must already be installed on the server.
Set the exact reviewed image digest in the command below. Preview the file changes, then execute only after reviewing them:
ansible-playbook -i inventory.ini deploy.yml --syntax-check
ansible-playbook -i inventory.ini deploy.yml \
-e 'container_image=python@sha256:YOUR_REVIEWED_DIGEST' --check --diff
ansible-playbook -i inventory.ini deploy.yml \
-e 'container_image=python@sha256:YOUR_REVIEWED_DIGEST'Change page_message in the playbook and rerun to update the page. It owns /opt/contoso-container; future runs can overwrite manual page edits. The command task is reported as changed whenever executed, even if Compose reuses the container. Check mode previews files and checks Compose availability, but skips deployment and HTTP proof. It is not a substitute for a real run.
8. Match the symptom to the layer
| Symptom | Check first | Avoid |
|---|---|---|
| Missing LAB_IMAGE | Correct runtime folder and valid .env line | Replacing the digest with an unreviewed latest tag |
| Image pull denied / rate limited | Official registry status, network and authorized account limits | Switching to an unknown mirror or publishing credentials |
| Bind mount missing | site directory exists; index.html is readable | Mounting your whole home directory |
| Unhealthy / exited | Compose logs, Docker status and memory/disk usage | Assuming healthcheck automatically restarts it |
| Port occupied | sudo ss -lntp and the exact mapping | Killing a process you did not identify |
| Local curl works, tunnel fails | Current SSH IP/key, laptop port and server target 8081 | Adding public inbound 8081 access |
| Content reverted after automation | Template and page_message are desired state | Maintaining conflicting manual and automated copies |
9. Remove the app, then remove the cloud bill
Inside the exact dedicated project folder, inspect the project before taking it down:
sudo docker compose --project-name contoso-container ps
sudo docker compose --project-name contoso-container down
sudo docker compose --project-name contoso-container psThis removes this project's container and Compose network. It keeps the bind-mounted HTML, Compose files, downloaded images and Docker installation. Do not run a global Docker prune on a shared host. This recipe has no named data volumes; other projects may.
- Close the SSH tunnel with Ctrl+C. That does not stop the VM.
- Keep or remove local/staged files and backup copies deliberately.
- Use your cloud VM guide's reviewed destroy/manual cleanup. Confirm retained disks, IPs and any other resources.
- Check costs after reporting catches up. Container CPU/memory limits and
compose downdo not cap or stop VM billing.