Ubuntu 26.04 Tutorial / Run a Container App with Persistent Data and Restricted Ports

Container images provide the runtime environment, while persistent data and published ports need separate decisions. This exercise installs Docker Engine and Compose on a dedicated Ubuntu 26.04 VM and keeps a host-only Nginx app’s data across container recreation.

Choose the installation source and privilege model

On a server already using Docker, containerd, or Kubernetes, inventory containers, volumes, repositories, and packages before planning changes. Do not blindly remove packages or mix installation methods. The procedure below targets a fresh Ubuntu VM; stop if docker.sources or docker.asc already exists at the indicated paths.

Docker’s Ubuntu installation guide identifies 26.04 support, conflicting packages, and repository installation steps.

Docker group membership and socket access grant powerful host privileges. This lesson uses explicit sudo rather than making the socket world-writable or adding every user to the docker group. Treat image execution as a privileged trust decision.

Install from the official repository

Save the complete script as ~/install-docker-lab.sh and run bash ~/install-docker-lab.sh. It stops before writing if the release or existing-file checks fail. Signed-By scopes the repository key to this source.

set -eu
. /etc/os-release
if [ "$ID" != ubuntu ] || [ "$VERSION_ID" != 26.04 ]; then
    printf "This lab requires Ubuntu 26.04.\n" >&2
    exit 1
fi
if [ -e /etc/apt/sources.list.d/docker.sources ] || [ -e /etc/apt/keyrings/docker.asc ]; then
    printf "Inspect the existing Docker repository first.\n" >&2
    exit 1
fi
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: ${UBUNTU_CODENAME:-$VERSION_CODENAME}
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo docker version
sudo docker compose version
sudo docker run --rm hello-world

Verify both client/server versions and the hello-world success message. Resolve package, signature, or network failures before continuing. Record actual installed versions; removing a repository does not uninstall the engine or delete its data.

Keep the Nginx site-configuration lab on 8080 and use host port 8081 for this container, retaining its internal port 80. If 8081 is occupied, inspect its owner with ss/compose ps and consistently substitute another verified host port in Compose and curl.

Define the Compose app and volume

Create a new ~/compose-lab directory and save the following as compose.yaml inside it. First use ss -lnt to check that 127.0.0.1:8081 is free. Omitting the host address may publish to other interfaces, so this lab explicitly binds loopback.

services:
  web:
    image: nginx:stable-alpine
    ports:
      - "127.0.0.1:8081:80"
    volumes:
      - lab-html:/usr/share/nginx/html
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
volumes:
  lab-html:

lab-html is a named volume separate from the container’s writable layer. The exercise starts with initial content and then writes its own index.html. The stable-alpine tag can change; reproducible deployments need a tested digest and update process. Adjust the example log limits to the workload.

Run, recreate, and verify persistent data

(
set -eu
cd ~/compose-lab
sudo docker compose config
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
sudo docker compose logs --tail 30 web
)

Start the app after configuration validation and image download succeed. Check container state and logs, then write the lab content below. Do not overwrite files in an existing project or volume containing real data.

(
set -eu
cd "$HOME/compose-lab"
sudo docker compose exec web sh -c 'printf "volume-lab\n" > /usr/share/nginx/html/index.html'
curl --fail http://127.0.0.1:8081/
sudo docker compose up -d --force-recreate
curl --fail http://127.0.0.1:8081/
sudo docker image inspect nginx:stable-alpine --format '{{json .RepoDigests}}'
)

Each start, recreation, and shutdown block changes into ~/compose-lab before running. A directory change inside parentheses does not persist after the block ends. Every Compose command must use the same compose.yaml to target the same project and volume. The volume-lab response before and after recreation shows that the app reads the same volume file. This does not prove a successful backup or complete application health. Another machine should not reach this loopback-published port; inspect actual bindings with ss and docker compose ps.

Review Docker’s firewall documentation: do not assume that UFW alone protects Docker-published ports.

Distinguish stopping from deleting data

(
set -eu
cd "$HOME/compose-lab"
sudo docker compose down
sudo docker volume ls
)

down removes this project’s containers and networks while retaining named volumes by default. Start again with up -d to verify the response. Do not use down -v or volume prune as routine stop commands: they can delete data. Coordinate application writes and test restoration to a separate location when backing up volumes.