How to use Docker on Ubuntu for reliable development environments

Docker gives Ubuntu developers a practical way to run applications with their dependencies packaged into isolated containers. Instead of installing every version of Node.js, Python, PostgreSQL, or Redis directly on the host system, you can describe an environment in configuration files and start it when required.

This approach is useful for web development, testing, automation, and small home-lab projects. A project can use one database version while another uses a different release, without turning your Ubuntu installation into a collection of conflicting packages. Containers also make it easier for a team to share the same setup.

Ubuntu is a particularly comfortable platform for Docker because it has strong package support, a large user community, and straightforward command-line tools. The instructions below suit Ubuntu 24.04 LTS, although the general workflow also applies to recent supported releases.

A Docker-based workflow can work just as well on a developer workstation in Melbourne as on a small server in Perth. If your connection relies on NBN upload speeds, image downloads may take a little patience the first time, but later starts are usually much faster because Docker reuses local images and layers.

Install Docker Engine and Compose

Ubuntu includes packages related to Docker, but the versions in the default repositories may not match the current Docker release. For development, Docker’s official repository is usually the better option because it provides Docker Engine, the Docker CLI, Buildx, and the Compose plugin together.

Start by removing conflicting packages, then install the repository prerequisites:

sudo apt update
sudo apt remove docker.io docker-doc docker-compose podman-docker containerd runc
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

Add the Docker repository using Ubuntu’s release information:

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

Check that the service is running and that the command-line tools are available:

sudo systemctl status docker
sudo docker run hello-world
docker compose version

The hello-world image confirms that the daemon can download an image, create a container, and run it. If Docker is inactive, start it with sudo systemctl enable --now docker. On a fresh Ubuntu desktop, this usually gets you to a working installation without needing Docker Desktop.

Let your user run Docker safely

By default, Docker commands require sudo because the Docker daemon runs with high privileges. You can add your account to the docker group to use commands such as docker ps without typing sudo every time:

sudo usermod -aG docker "$USER"

Log out and back in, or start a new login session, before testing:

docker run hello-world

The convenience comes with an important security consideration. Membership of the docker group effectively grants root-level control over the host, because containers can be started with access to sensitive parts of the system. Only add trusted local accounts, and do not treat containers as a complete security boundary for untrusted code.

Keep the daemon and images updated, particularly on a machine exposed to the internet. Use docker ps to see running containers, docker image ls to inspect downloaded images, and docker system df to understand storage use. Docker’s image cache can become sizeable, which matters on laptops with small SSDs or on a Raspberry Pi using limited storage.

If you are building a compact ARM development machine, the Raspberry Pi 5 guide explains a suitable Ubuntu installation path. Docker images must support the machine’s architecture, so check whether an image offers linux/arm64 before relying on it.

Create a project with Docker Compose

Docker Compose is the most convenient way to describe a multi-container development environment. A compose.yaml file can define an application, database, cache, network, ports, environment variables, and persistent storage in one place.

Create a project directory and a basic Compose file:

mkdir -p ~/projects/sample-app
cd ~/projects/sample-app
nano compose.yaml

Use this example for a small PostgreSQL service:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: change-this-locally
      POSTGRES_DB: appdb
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  postgres_data:

Start it in the background with:

docker compose up -d

Then inspect the service and its logs:

docker compose ps
docker compose logs -f db

The named volume keeps database files when the container is recreated. Running docker compose down removes the containers and network but leaves the volume intact. To remove the data as well, use docker compose down -v, but be careful: that permanently deletes the database stored in the Compose volume.

For real projects, place passwords in a .env file or use Docker secrets where appropriate. Do not commit credentials to Git. A local .env file should normally be listed in .gitignore, while a safe .env.example can show teammates which variables they need to provide.

Build an application container

A Dockerfile describes how to turn application source code into an image. For a Python service, a small example might look like this:

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["python", "app.py"]

Add an application service to compose.yaml:

  web:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - .:/app
    environment:
      DATABASE_URL: postgresql://appuser:change-this-locally@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy

The build: . setting tells Compose to use the Dockerfile in the current directory. The bind mount maps your source code into the container, allowing edits made in an Ubuntu editor such as Visual Studio Code, Vim, or Kate to appear immediately inside the running service.

Container-to-container communication uses service names, not localhost. The web container reaches PostgreSQL at db:5432, because db is the service name on the Compose network. From the Ubuntu host, you can access the published web port at http://localhost:8000.

Rebuild after changing dependencies:

docker compose up -d --build

Use .dockerignore to keep unnecessary files out of the build context:

.git
__pycache__
*.pyc
.env
node_modules

For production images, use a multi-stage build, run as a non-root user, pin important dependency versions, and avoid copying development files into the final image. A development Compose file can remain convenient while a separate production configuration stays smaller and more restrictive.

Make the workflow faster and easier to diagnose

A good development environment should be predictable when it starts and understandable when it fails. docker compose logs -f is the first place to look for application errors, failed database connections, and incorrect environment variables. docker inspect container_name shows mounts, ports, networks, and other runtime details.

Use these commands regularly:

docker compose ps
docker compose exec web sh
docker compose restart web
docker compose down
docker stats

docker compose exec web sh opens a shell inside a running service, which is useful for checking files, testing network access, or running framework commands. If the image includes Bash, replace sh with bash.

Port conflicts are common when another local service is already listening. Change only the host side of the mapping, such as "8001:8000", and open the application at port 8001. A connection failure to a database often means the application started before the database was ready; the health check and depends_on condition help, though the application should still retry connections gracefully.

Bind mounts can produce slower file watching on some setups, especially with large JavaScript projects. Keep dependency directories such as node_modules in a named volume rather than sharing them directly from the host. This can make a noticeable difference on laptops and on network-mounted project folders.

Clean unused resources occasionally:

docker system df
docker image prune

Use broader cleanup commands carefully. docker system prune removes stopped containers, unused networks, dangling images, and build cache, while adding --volumes can delete unused data volumes. On a home server with limited disk space, scheduled monitoring is safer than deleting everything during a troubleshooting session.

Use containers responsibly on Ubuntu

Containers are excellent for repeatable development, but they do not remove the need for ordinary system administration. Keep Ubuntu patched, restrict published ports, and avoid exposing databases directly to the public internet. A database normally needs to be reachable only by application containers, so you can omit its ports section entirely when host access is unnecessary.

Choose maintained base images from reputable publishers and prefer specific tags over latest. A tag such as postgres:16 communicates a major-version expectation, while a digest can provide even stronger reproducibility when a project needs identical builds over time. Scan images for known vulnerabilities before deploying them beyond a local workstation.

Australian developers working with remote teams may also benefit from a Compose file that documents all required services clearly. Colleagues in Sydney, Adelaide, or regional areas can clone the repository and start the same database and cache versions without manually reproducing a long list of Ubuntu packages. Teams using Australian cloud regions should still keep local development data separate from shared staging or production data.

Docker also fits well into continuous integration. A CI runner can build the image, run the test suite in Compose, and publish a versioned image only after the checks pass. Store secrets in the CI platform rather than in the repository, and make sure test containers use disposable volumes so one run cannot contaminate the next.

The essential workflow is simple: install Docker Engine from a trusted repository, verify it with a small container, define services in Compose, keep persistent data in named volumes, and inspect logs when something goes wrong. With that foundation, an Ubuntu machine can host clean, repeatable development environments for web apps, databases, command-line tools, and home-lab experiments.

What matters most is keeping the environment described in files rather than in memory. A well-maintained Dockerfile and compose.yaml make a project easier to start, share, test, and repair, whether it is running on a workstation in Brisbane or a small Ubuntu server at home.