So, I have to say that this is a long time coming.

I’ve hardly ever used docker in my daily life, because I don’t build anything and arn’t maintaining any systems.

But now that I am maintaining openweightmodels, which uses docker extensively, I’ll have to really brush up on what it’s doing.

So here goes.

How docker works

The core idea of docker revolves around images. Images are static files that you can download - think of them like binaries used to install a certain software.

You can download images from anywhere, or you can pull them from docker hub via:

docker pull docker/welcome-to-docker

The docker desktop app provides a nice GUI to interact with Docker. Once you download images, you can find them in the Images section of the app.

From the image, you can start a container

The container is the actual application or service itself. It’s like a mini-VM - or a frontend or backend service. The key action here is run. You run an image. Running an image spawns a container from the image:

docker run path_to_image

Now, you often use it in the context of several important flags, such as:

docker run --detach --name welcome --publish 8080:80 docker/welcome-to-docker

In this case, we run in detach mode, give it a name, specify the port mapping between host and container, and the name of the image to run.

It’s a bit of a mouthful, but there you have it.

Now, if you want to stop the service, there’s two commands you can do:

  • docker stop: this pauses/halts the container. The status is Exited, data preserved, and restartable with docker start

  • docker rm: container is destroyed and deleted. Data inside deleted, not restartable - the instance is gone, you need to spin up a brand new instance with docker run

So far so good? From images, you start containers. Containers are mini environments for apps. You can stop, or delete containers.

Now, often times, you don’t just need to start one app. Think of a typical development workflow, where you need to start a frontend service, and backend service, and some databases. In docker parlace, each of them can be containers that you start. You need a system of orchestrating them: and that system is Docker Compose.

compose.yaml

The compose.yaml file is your full stack app orchestration definition. It looks something like this.

compose.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
services:
  proxy:
    image: traefik:v3.6
    command: --providers.docker
    ports:
      - 80:80
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

  backend:
    build:
      context: ./
      target: backend-dev
    environment:
      MYSQL_HOST: mysql
      MYSQL_USER: root
      MYSQL_PASSWORD: secret
      MYSQL_DB: todos
    depends_on:
      mysql:
        condition: service_healthy
    develop:
      watch:
        - path: ./backend/src
          action: sync
          target: /usr/local/app/src
        - path: ./backend/package.json
          action: rebuild
    labels:
      traefik.http.routers.backend.rule: Host(`localhost`) && PathPrefix(`/api`)
      traefik.http.services.backend.loadbalancer.server.port: 3000

  client:
    build:
      context: ./
      target: client-dev
    develop:
      watch:
        - path: ./client/src
          action: sync
          target: /usr/local/app/src
        - path: ./client/package.json
          action: rebuild
    labels:
      traefik.http.routers.client.rule: Host(`localhost`)
      traefik.http.services.client.loadbalancer.server.port: 5173

  mysql:
    image: mysql:9.3
    volumes:
      - todo-mysql-data:/var/lib/mysql
    environment: 
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: todos
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 5

  phpmyadmin:
    image: phpmyadmin
    environment:
      PMA_HOST: mysql
      PMA_USER: root
      PMA_PASSWORD: secret
    labels:
      traefik.http.routers.phpmyadmin.rule: Host(`db.localhost`)
      traefik.http.services.phpmyadmin.loadbalancer.server.port: 80

volumes:
  todo-mysql-data:

Now, how to precisely define the compose.yaml file is a topic for a future post. But once you have the definition of the compose file, you can run the following to start a lot of containers at once:

docker compose up --build --detach

The key command here: docker compose up

This reads the compose.yaml file, and then starts the services.

The build option builds the frontend and API images. Compose also pulls the images from supporting services and connects the containers.

After running that, you will see the following:

You notice that several containers are started together.

IN addition, you can see that there is an image hash 3c5b... that’s being writen. That is actually the image built for the backend services, that you can actually see in the Docker GUI:

To see all the containers that are started, you can run:

docker compose ps

Finally, you can also also build an image that packages everything together

But first, you need to have a Dockerfile. The dockerfile is a set of instructions for building the image.

Example Dockerfile
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
###################################################
# Stage: base
# 
# This base stage ensures all other stages are using the same base image
# and provides common configuration for all stages, such as the working dir.
###################################################
FROM node:22 AS base
WORKDIR /usr/local/app

################## CLIENT STAGES ##################

###################################################
# Stage: client-base
#
# This stage is used as the base for the client-dev and client-build stages,
# since there are common steps needed for each.
###################################################
FROM base AS client-base
COPY client/package.json client/package-lock.json ./
COPY .npmrc .
RUN npm ci
COPY client/.eslintrc.cjs client/index.html client/vite.config.js ./
COPY client/public ./public
COPY client/src ./src

###################################################
# Stage: client-dev
# 
# This stage is used for development of the client application. It sets 
# the default command to start the Vite development server.
###################################################
FROM client-base AS client-dev
CMD ["npm", "run", "dev"]

###################################################
# Stage: client-build
#
# This stage builds the client application, producing static HTML, CSS, and
# JS files that can be served by the backend.
###################################################
FROM client-base AS client-build
RUN npm run build




###################################################
################  BACKEND STAGES  #################
###################################################

###################################################
# Stage: sqlite3-build
#
# Compiles the sqlite3 native binary in an isolated stage so that the
# backend stages can use ignore-scripts=true without breaking sqlite3.
# Only the compiled artifact is copied across — the scripts never land
# in any shipped image layer.
###################################################
FROM base AS sqlite3-build
COPY backend/package.json backend/package-lock.json ./
COPY .npmrc .
RUN npm ci && cd node_modules/sqlite3 && ../.bin/node-gyp rebuild

###################################################
# Stage: backend-base
#
# This stage is used as the base for the backend-dev and test stages, since
# there are common steps needed for each.
###################################################
FROM base AS backend-dev
COPY backend/package.json backend/package-lock.json ./
COPY .npmrc .
RUN npm ci
COPY --from=sqlite3-build /usr/local/app/node_modules/sqlite3/build \
    ./node_modules/sqlite3/build
COPY backend/spec ./spec
COPY backend/src ./src
CMD ["npm", "run", "dev"]

###################################################
# Stage: test
#
# This stage runs the tests on the backend. This is split into a separate
# stage to allow the final image to not have the test dependencies or test
# cases.
###################################################
FROM backend-dev AS test
RUN npm run test

###################################################
# Stage: final
#
# This stage is intended to be the final "production" image. It sets up the
# backend and copies the built client application from the client-build stage.
#
# It pulls the package.json and package-lock.json from the test stage to ensure that
# the tests run (without this, the test stage would simply be skipped).
###################################################
FROM base AS final
ENV NODE_ENV=production
COPY --from=test /usr/local/app/package.json /usr/local/app/package-lock.json ./
COPY .npmrc .
RUN npm ci --production && \
    npm cache clean --force
COPY --from=sqlite3-build /usr/local/app/node_modules/sqlite3/build \
    ./node_modules/sqlite3/build
COPY backend/src ./src
COPY --from=client-build /usr/local/app/dist ./src/static
EXPOSE 3000
CMD ["node", "src/index.js"]

Once you have the docker file, you can build the image with this command:

docker build --tag xiaogit00/getting-started-todo-app .

The --tag flag gives the image a name, and the username prefix identifies where the image will be stored on Docker Hub. . tells Docker to find the Dockerfile and app source in the current directory.

Now, having built the image, you can of course run it:

docker run --detach --name todo --publish 8080:3000 xiaogit00/getting-started-todo-app

Lastly, logging in, and cleaning up

These are the commands to log in and share the images through the registry:

1
2
docker login
docker push xiaogit00/getting-started-todo-app

Once you share it, it’ll be publicly available through docker hub.

These are the commands for cleaning up:

docker rm --force todo –> remove the container

docker compose down --volumes –> completely stops your running containers, deletes them, and permanently deletes the storage volumes associated with the project.

So that’s about it!

To recap

docker pull imagePath - pulls the image
docker run imagePath - starts the container docker compose up - starts all containers in compose.yaml
docker compose ps - checks the containers that are up
docker build - builds an image from a Dockerfile
docker rm containerName - removes a container
docker compose down --volumes - stops all running containers and deletes them