Infrastructure · Containers

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.

Use only your dedicated lab. Docker installation changes the server and its firewall rules. Existing VM, disk, public IP and traffic costs continue. Downloads consume bandwidth/disk space. Do not expose ports 8080/8081, the Docker daemon, or a database publicly.

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.

PieceWhat it doesWhat it does not do
ImagePackages Python and its runtime filesAutomatically update when a new tag is published
ContainerRuns a process using the host kernelProvide the same isolation boundary as a separate VM
Bind mountLets it read this host's public HTML directoryBack up that directory
ComposeDescribes ports, limits, mounts and health checksCreate cloud networking or stop cloud billing
RegistryStores downloadable imagesGuarantee 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.

Docker container conceptsPython HTTP server limitations

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:

Inside the cloud VM · verify the engine
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.

Docker-published ports can bypass UFW rules. Binding to loopback plus retaining the cloud's restrictive firewall avoids adding a public listener in this recipe. Do not assume UFW alone protects a port published on all interfaces. Never mount the Docker socket into this demo or enable a remote Docker TCP API.

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:

Inside the cloud VM · new staging folder
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:

Laptop Ubuntu / WSL · AWS example transfer
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:

.env · exact image reference, not a secret
LAB_IMAGE=python@sha256:REPLACE_WITH_THE_COMPLETE_64_CHARACTER_DIGEST

Then protect the reference file and make only the public demo readable by the container user:

Inside the staging folder · file permissions
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-container

Check 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:8081 leads to container 8080. 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.

Docker Official Image: PythonCompose service settings

4. Start, wait, and inspect

Inside /opt/contoso-container · run the dedicated project
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.

Prove it: the container is healthy, loopback HTTP responds, and the cloud firewall has no new inbound 8080/8081 rule. Host Nginx can still run separately on port 80.

Compose up and wait

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:

Laptop Ubuntu / WSL · AWS container tunnel
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-IP

For 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.

Laptop 8082Loopback only
SSH port 22Verified, encrypted connection
Server 8081Docker loopback binding
Container 8080Python app

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:

Inside the VM · preserve and update the demo
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:

Inside the VM · explicitly restore the baseline
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:

Laptop Ubuntu / WSL · configure one lab
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.

This is configuration automation on an existing VM. It does not create extra cloud resources, remove the VM, install Docker, manage TLS, or turn a toy HTTP server into a production service.

8. Match the symptom to the layer

SymptomCheck firstAvoid
Missing LAB_IMAGECorrect runtime folder and valid .env lineReplacing the digest with an unreviewed latest tag
Image pull denied / rate limitedOfficial registry status, network and authorized account limitsSwitching to an unknown mirror or publishing credentials
Bind mount missingsite directory exists; index.html is readableMounting your whole home directory
Unhealthy / exitedCompose logs, Docker status and memory/disk usageAssuming healthcheck automatically restarts it
Port occupiedsudo ss -lntp and the exact mappingKilling a process you did not identify
Local curl works, tunnel failsCurrent SSH IP/key, laptop port and server target 8081Adding public inbound 8081 access
Content reverted after automationTemplate and page_message are desired stateMaintaining 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:

Inside /opt/contoso-container · dedicated project cleanup
sudo docker compose --project-name contoso-container ps
sudo docker compose --project-name contoso-container down
sudo docker compose --project-name contoso-container ps

This 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.

  1. Close the SSH tunnel with Ctrl+C. That does not stop the VM.
  2. Keep or remove local/staged files and backup copies deliberately.
  3. Use your cloud VM guide's reviewed destroy/manual cleanup. Confirm retained disks, IPs and any other resources.
  4. Check costs after reporting catches up. Container CPU/memory limits and compose down do not cap or stop VM billing.
Finished: explain the VM/container boundary, each port in the tunnel, the pinned image, the tested update/restore, and what still exists after Compose down.