Once Docker is installed, what you actually need to understand isn’t dozens of docker commands — it’s how Docker organizes an application.

Docker’s core model is actually quite simple: an application and its execution environment are packaged into an image, and Docker creates a container from that image; data that needs to persist long-term inside a container goes into a volume or a directory on the host; different containers communicate through a Docker network; and when an application consists of multiple containers, Docker Compose is used to organize all of these settings together.

flowchart LR
    A[Dockerfile / Registry]
    B[Image]
    C[Container]
    D[Volume / Bind Mount]
    E[Docker Network]
    F[Other Containers]

    A --> B
    B -->|docker run| C
    C --> D
    C --> E
    E --> F

Once you understand the relationships between these objects, most Docker commands are just about creating, inspecting, starting, and deleting them.

This article focuses on these concepts and how they’re used in real deployments, rather than treating Docker as a command reference manual.

1. Image and Container

Docker’s two most important concepts are image and container.

An image is a read-only software template containing an application and everything needed to run it. For example:

nginx:alpine
python:3.13-slim
postgres:18

are all Docker images. Images are usually obtained from a registry:

docker pull nginx:alpine

or built yourself from a Dockerfile:

docker build -t myapp:1.0 .

A container, on the other hand, is a running instance created from an image. For example:

docker run nginx:alpine

roughly goes through:

flowchart LR
    A[nginx:alpine Image]
    B[Create Container]
    C[Start Container]
    D[Running Process]

    A -->|docker run| B
    B --> C
    C --> D

There’s a point that’s easy to get confused about here:

docker run doesn’t mean “run an existing container” — it creates a brand new container from an image, and then starts it.

So running:

docker run nginx:alpine

twice creates two different containers. If a container already exists but has just been stopped, you should use:

docker start container_name

instead of running docker run again.

1.1 A Typical docker run

For example, running an Nginx:

docker run -d \
    --name web \
    --restart unless-stopped \
    -p 8080:80 \
    nginx:alpine

Even though there are a lot of flags here, they actually only describe a handful of things:

  • -d / --detach: runs the container in the background.
  • --name web: names the container web.
  • --restart unless-stopped: if the Docker daemon or the machine restarts, this container comes back automatically; if the administrator stops it deliberately, it stays stopped.
  • -p 8080:80: maps TCP port 8080 on the host to TCP port 80 in the container.
  • nginx:alpine is the actual image used to create this container.

So docker run can be understood as:

docker run [runtime configuration for the container] image

rather than simply “starting Docker.”

1.2 A Container’s Lifecycle

There really aren’t that many container commands you actually use day to day.

docker ps # view running containers
docker ps -a # view containers, including stopped ones
docker stop web # stop
docker start web # start
docker logs web # view logs
docker logs -f web # continuously view logs
docker exec -it web sh # get a shell inside the container
docker rm web # remove the container
docker rm -f web # --force, forcibly remove even if the container is still running
docker exec -it web bash # run a bash command inside the container

Day-to-day maintenance basically revolves around:

run
ps
logs
exec
stop
start
rm

2. A Container Shouldn’t Store Important Data

A container has its own filesystem.

For example, get inside a container:

docker exec -it web sh

and then create, inside it:

/data/test.txt

This file genuinely exists, but it belongs to that container’s writable layer.

If the container gets removed:

docker rm -f web

and a new container is then created from the same image, that data from before doesn’t automatically come back.

This isn’t a flaw in Docker — it’s how containers are designed to work:

A container should be freely removable and re-creatable at any time; anything genuinely important should live independently of the container’s lifecycle.

flowchart TD
    A[Image]
    A --> B[Container]
    A --> C[New Container]

    B -->|deleted| D[Container Writable Layer disappears]

    E[Persistent Storage]
    B --> E
    C --> E

Docker’s two most common ways of persisting data are:

  • bind mount
  • volume

2.1 Bind Mount

A bind mount directly mounts a real directory from the host into the container. For example:

Host                       Container

/data/nginx/html    <-->    /usr/share/nginx/html

can be written as:

docker run -d \
    --name web \
    -p 8080:80 \
    --mount type=bind,source=/data/nginx/html,target=/usr/share/nginx/html \
    nginx:alpine

After that, the container accessing /usr/share/nginx/html is actually accessing /data/nginx/html on the host.

Docker also supports the shorter -v:

-v /data/nginx/html:/usr/share/nginx/html

Both forms are common for simple cases, but --mount is more explicit about its meaning.

A bind mount is well suited to:

  • configuration files;
  • static files;
  • source code during development;
  • data that needs to be read and modified directly from the host.

For example:

volumes:
  - ./nginx.conf:/etc/nginx/nginx.conf:ro

Here, :ro means read-only — the container can only read the file, and can’t modify the configuration on the host.

Here’s an actual recorded session: starting an nginx with a bind-mounted static file mounted read-only, modifying the file’s content directly on the host with no rebuild and no container restart, and immediately seeing the update in the browser (curl is used here instead); finally, the container is removed, confirming the file on the host is completely unaffected:

An actual recording of docker run with a bind mount, immediately reflecting file changes made on the host

2.2 Volume

The other approach is to let Docker manage the storage location itself. For example:

docker volume create postgres-data

then:

docker run -d \
    --name postgres \
    --mount type=volume,source=postgres-data,target=/var/lib/postgresql/data \
    postgres

Here you don’t need to care which specific directory on the host postgres-data corresponds to.

From a usage perspective, the two can be roughly distinguished as follows:

Bind MountVolume
Path on the hostYou specify it yourselfManaged by Docker
Easy to browse the files directlyYesUsually unnecessary
Configuration filesVery well suitedGenerally unnecessary
Database dataPossibleUsually a better fit
Coupling with the host’s directory structureStrongerWeaker

So a genuinely practical rule of thumb is:

Use a bind mount when you want to manage the files yourself directly; use a volume for data that’s mainly managed by the application itself.

For example:

services:
  app:
    volumes:
      - ./config:/app/config

  database:
    volumes:
      - database-data:/var/lib/database

volumes:
  database-data:

This is also a common way many long-running Docker services are organized.

3. Docker Network and Port Mapping

A Docker container has its own network environment by default.

Suppose an Nginx container listens internally on:

0.0.0.0:80

This doesn’t mean TCP port 80 on the host automatically becomes this Nginx.

To access it via the host, you need to explicitly publish the port:

docker run -d \
    -p 8080:80 \
    nginx:alpine

Here 8080 is the host port, and 80 is the container port. So:

flowchart LR
    A[Browser]
    B[Docker Host<br/>:8080]
    C[Nginx Container<br/>:80]

    A --> B
    B -->|Port Mapping| C

Only accessing:

http://host-ip:8080

gets forwarded by Docker to port 80 on the Nginx container.

If you only want access from the local machine:

docker run -d \
    -p 127.0.0.1:8080:80 \
    nginx:alpine

This is very useful when there’s already a reverse proxy like Nginx or Caddy in front.

How Containers Talk to Each Other

Port mapping mainly solves the problem of:

How something outside Docker accesses a container.

Communication between containers, on the other hand, mainly relies on the Docker network. For example, create a network:

docker network create app-network

Start the database:

docker run -d \
    --name db \
    --network app-network \
    postgres

Then start the application:

docker run -d \
    --name app \
    --network app-network \
    myapp

At this point, app doesn’t need to know db’s specific IP address.

It can access it directly as:

db:5432

because containers within the same user-defined Docker network can resolve each other by name via DNS.

flowchart LR
    subgraph Docker Network
        A[App<br/>app]
        B[PostgreSQL<br/>db:5432]
    end

    A -->|db:5432| B

This point matters a great deal.

A Docker container’s IP address is not something that should ever be hardcoded into a configuration file. A container’s IP may change after it’s re-created, but the service/container name stays stable.

So don’t write:

postgres://172.18.0.3:5432

Write instead:

postgres://db:5432

Similarly, keep in mind:

127.0.0.1 inside a container always refers to that container itself.

For example, suppose Nginx and Nextcloud each run in their own container:

Nginx Container
Nextcloud Container

Then, inside Nginx:

proxy_pass http://127.0.0.1:8080;

points to port 8080 on the Nginx container itself, not Nextcloud.

If the two services are on the same Docker network, this should instead be:

proxy_pass http://nextcloud:8080;

This is also one of the most important things to understand about Docker networking.

4. Why You Need Docker Compose

For a single container, docker run is convenient enough.

But once a service gets even slightly more complex, this quickly turns into:

docker run \
    --name app \
    --network app-network \
    --restart unless-stopped \
    -e DATABASE_HOST=db \
    -e DATABASE_USER=app \
    -v ./config:/app/config \
    -p 8000:8000 \
    myapp:latest

And if there’s also:

PostgreSQL
Redis
Nginx
Worker

then the number of docker run commands you need to maintain just keeps growing.

The real problem isn’t that the command is too long — it’s that:

The application’s deployment configuration only exists inside a shell command that happened to be run once.

What Docker Compose does is save these containers’ runtime parameters into a configuration file that can actually be version-controlled.

For example:

services:
  app:
    image: myapp:latest
    restart: unless-stopped
    ports:
      - "8000:8000"
    environment:
      DATABASE_HOST: db
      DATABASE_USER: app
    volumes:
      - ./config:/app/config
    depends_on:
      - db

  db:
    image: postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: example
      POSTGRES_DB: app
    volumes:
      - database-data:/var/lib/postgresql/data

volumes:
  database-data:

usually saved as:

compose.yaml

and then:

docker compose up -d

Compose creates the containers, network, and volumes according to the configuration.

flowchart TD
    A[compose.yaml]
    A --> B[App Container]
    A --> C[Database Container]
    A --> D[Default Network]
    A --> E[database-data Volume]

    B --> D
    C --> D
    C --> E

There’s a very useful default behavior here:

Services within the same Compose project are, by default, added to the same network, and can access each other directly via their service name.

So the following:

DATABASE_HOST: db

just works.

There’s no need to know the database container’s IP, and there’s no need to expose PostgreSQL’s port 5432 to the host just for the internal app → db communication. By default, Compose creates a network for the project and provides internal DNS resolution for service names.

So the database only needs:

db:
  image: postgres

without needing:

ports:
  - "5432:5432"

unless something outside the host or Docker network genuinely needs to access PostgreSQL directly.

This helps cut down on ports being exposed unnecessarily.

5. The Most Commonly Used Settings in Compose

A real-world Compose service is usually just describing a handful of things:

services:
  app:
    image: example/app:latest
    restart: unless-stopped

    ports:
      - "8080:8000"

    environment:
      APP_ENV: production

    volumes:
      - ./config:/app/config:ro
      - app-data:/app/data

    networks:
      - frontend

These settings can be understood as:

image         which software environment to use
ports         which ports get exposed to the host
environment   what environment variables to give the application
volumes       which data lives outside the container
networks      which containers it can talk to
restart       what happens once the container exits

These few fields already cover the vast majority of homelab and ordinary server deployments.

Environment

Environment variables can be written directly:

environment:
  APP_ENV: production
  DATABASE_HOST: db

or placed into a separate file:

env_file:
  - .env

For passwords, tokens, and similar information, it’s not recommended to commit them directly to a public Git repository.

Ports

ports:
  - "8080:80"

still means:

Host :8080 -> Container :80

If you only want to allow access from the host machine itself:

ports:
  - "127.0.0.1:8080:80"

This works very well for:

Internet

Nginx

127.0.0.1:8080

Application Container

Volumes

Bind mount:

volumes:
  - ./config:/app/config

Named volume:

volumes:
  - app-data:/app/data

declared at the outermost level:

volumes:
  app-data:

Docker itself also distinguishes these two storage models: a bind mount uses an explicit path on the host, while a named volume’s storage location is managed by Docker; for persistent data mainly used by the container, a volume is usually the more appropriate default choice.

Networks

For a simple Compose project, you usually don’t even need to write:

networks:

Compose automatically creates a default network.

You only need to configure it yourself when you need more explicit network isolation.

For example:

services:
  nginx:
    image: nginx
    networks:
      - frontend

  app:
    image: myapp
    networks:
      - frontend
      - backend

  db:
    image: postgres
    networks:
      - backend

networks:
  frontend:
  backend:

The structure then becomes:

flowchart LR
    subgraph frontend
        A[Nginx]
        B[App]
    end

    subgraph backend
        C[App]
        D[Database]
    end

    A --> B
    C --> D

This way, Nginx can reach App, but can’t reach the Database directly.

For an ordinary service, you don’t need to design a complex network layout upfront — Compose’s default network is already enough. Only add a custom network once there’s a genuine need for isolation, cross-project communication, or a shared network with a reverse proxy.

6. Using Docker Compose Day to Day

Compose’s biggest change is turning what you manage from “individual containers” into “an application.” Below is an actual recorded terminal session, covering the full process from starting the service, checking its status, confirming it’s actually reachable, viewing logs, and shutting it down:

An actual recording of docker compose up, ps, logs, and down

Start it, from the directory containing compose.yaml:

docker compose up -d

Check the status:

docker compose ps

View the logs:

docker compose logs

Follow them continuously:

docker compose logs -f

View just one service:

docker compose logs -f app

Re-read the configuration and update the services:

docker compose up -d

If the images have been updated:

docker compose pull
docker compose up -d

Stop the services:

docker compose stop

Start them again:

docker compose start

If you want to remove the containers and network created by the current project:

docker compose down

By default, docker compose down doesn’t remove the named volumes declared in the compose file, so a normal redeployment won’t wipe out your database data along with it. Only explicitly adding:

docker compose down -v

removes the named volumes declared by the project — for important data like a database, this command needs to be used with particular caution.

The most commonly used flow for routinely updating a Compose service is really just:

docker compose pull
docker compose up -d
docker compose logs -f

If you’ve modified compose.yaml, you usually don’t need to manually remove the existing containers first either. Running:

docker compose up -d

again lets Compose recreate whichever containers need updating, based on what’s changed in the configuration.

This is also why, on a long-running server, I’d recommend using Compose over keeping around a pile of docker run commands.

What ultimately needs to be kept around long-term can be reduced down to just:

my-service/
├── compose.yaml
├── .env
├── config/
└── other-bind-mounted-data/

while the container itself should be freely re-creatable at any time.

The whole idea can be summarized as:

flowchart TD
    A[compose.yaml]
    B[Image]
    C[Container]
    D[Persistent Data]
    E[Docker Network]

    A -->|defines how it runs| C
    B -->|creates| C
    C -->|can be deleted and recreated| C
    C --> D
    C --> E

    D -->|independent of the container's lifecycle| F[kept long-term]

What’s genuinely worth mastering about Docker isn’t dozens of commands — it’s the boundary between these few objects:

An image is a template, a container is a running instance; a container can be recreated at any time, and anything important should live in a volume or bind mount; containers communicate through a network, and expose services to the host through port mapping; Docker Compose then saves all of this runtime configuration into a file that can be repeatedly deployed and version-controlled.

Once this mental model is in place, most Docker configurations, even ones you’ve never seen before, can be inferred just from the configuration file itself.