跪拜 Guibai
← Back to the summary

A Dockerfile Is a Milk-Tea SOP: Build and Ship a Full-Stack Todos App

A Dockerfile is like a "milk tea recipe"? Understanding Docker build and publish with a todos full-stack project

In the last article we explained how a request travels inside Docker (nginx reverse proxy + port mapping), and many readers commented that they wanted to see "how to actually publish a project as a Docker image." This article continues from there.

First, one sentence:

Mixue Bingcheng's SOP: add milk, add more milk, add 3 spoons of sugar, shake well. Anyone who follows it gets the same taste, and that's how a chain store works.

A Dockerfile is the programmatic version of that SOP.

It's a plain-text "recipe file" that lists step-by-step "how to cook." Docker reads this file and automatically produces an identical image—no human memory, no word-of-mouth, any person on any computer gets exactly the same output.

This article uses a real todos full-stack project (React frontend + NestJS backend + nginx) to walk you through the complete process of building it into an image and publishing it.


1. Dockerfile: an automatically executable recipe

An ordinary recipe is for humans; a Dockerfile is for the Docker engine to read. It looks like this:

# 1. Choose a base image: it already has the Node environment installed
FROM node

# 2. Switch to the /app folder inside the container and set it as the working directory
WORKDIR /app

# 3. Copy the "ingredients" (code) in
COPY package*.json ./

# 4. "Cook" step by step: install dependencies
RUN npm install

# 5. Copy all remaining code into the container
COPY . .

# 6. Tell Docker which port this container should expose
EXPOSE 3000

# 7. Serve: the command to run when the container starts
CMD ["node", "server.js"]

Each line is an instruction, executed top to bottom in order, just like a recipe's "add milk, add more milk, add 3 spoons of sugar, shake well."

Dockerfile instruction Analogy to Mixue SOP
FROM Choose which brand of ingredients / base
WORKDIR Chop on the workbench (switch to which directory)
COPY Put the prepared ingredients into the pot
RUN Cook in the pot (execute a command)
EXPOSE Tell the packaging box which window this service will open
CMD What to do when serving (container start command)

Memory trick: FROM starts it, COPY/RUN do the middle work, CMD finishes with startup. When you see a Dockerfile, find these three first and the structure becomes clear.


2. From build to publish: four steps

Turning a recipe into a service that actually runs comes down to these 4 commands:

docker build -t my-todos .   # 1. Build an image according to the Dockerfile
docker login                  # 2. Log into an image registry (e.g., Docker Hub / private registry)
docker push my-todos          # 3. Push the image to the remote registry
docker pull my-todos          # 4. Pull it down and run it on another machine

Let's break them down one by one:

docker build -t my-todos .

-t gives the image a name, and the trailing . means "use the Dockerfile in the current directory."

docker build -t image-name .
   ↑ the middle part is the command     ↑ find the Dockerfile in the current directory

Key point: build is not "reading through a file"; it executes every instruction in the Dockerfile, and each layer generates a cache layer. So changing a small config and rebuilding is fast—only the changed layers get rebuilt.

docker login → ③ docker push

Once the image is built, it's useless sitting only locally. To share it with a team or deploy it to a server, push it to an image registry (similar to a git remote repository, but it stores "disc images" rather than code).

docker login      # Log into the registry, verify identity
docker push my-todos   # Upload the local image to the registry

After uploading, anyone or any machine that can access the registry can docker pull my-todos to pull it down.

docker pull + run it

Pull the image from the registry, run a container, and deployment is done:

docker pull my-todos
docker run -p 3000:3000 my-todos

A Dockerfile is one of the standard ways to publish a project—its value lies in: "Build once, run anywhere." Local dev environment, test environment, production environment all use the same image, eliminating "it works on my machine but not yours."


3. Hands-on: how the todos full-stack project is assembled into images

That was the theory; now let's look at what our real todos project looks like. It's a three-piece set:

todos-fullstack/
├── todos/          # Frontend: React + TypeScript + zustand
├── todos-backend/  # Backend: NestJS + Todo Module
└── (nginx)         # Reverse proxy: frontend 80 → backend 3000

The project architecture is very typical:

Browser
  │  :80
  ▼
nginx (reverse proxy)
  │  forward
  ▼
NestJS backend  :3000
  ▲
  └── Todo Module (CRUD: create, read, update, delete)

Each part has its own Dockerfile

Frontend (React + Vite):

FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build          # Package static files

FROM nginx
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

Backend (NestJS):

FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build          # Compile TS to dist
EXPOSE 3000
CMD ["node", "dist/main.js"]

How cross-origin issues are solved in this architecture

When the frontend requests the backend, the browser blocks cross-origin requests (frontend is on port 80, backend on port 3000, different ports = cross-origin). Two mainstream solutions:

Solution 1: Nginx reverse proxy (recommended, for production) Make the frontend and backend same-origin—all requests go through port 80, forwarded by nginx:

server {
    listen 80;
    location /api/ {
        proxy_pass http://backend:3000;   # 80 → 3000, frontend is unaware
    }
}

After making them same-origin, the browser never considers it cross-origin. A permanent fix.

Solution 2: Backend enables CORS (for development) The backend directly allows cross-origin, suitable for local debugging. In NestJS, one line:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors({ origin: true, credentials: true });
  await app.listen(3000);
}

Remember: In production, mostly use Nginx reverse proxy (same-origin); in development, you can take a shortcut with CORS. Both aim to solve cross-origin, just placed in different locations.


4. Command quick reference

To wrap up, here's a table of build-and-publish related commands:

Command Purpose Analogy
docker build -t name . Build an image from a Dockerfile Make a cup of milk tea following the SOP
docker login Log into an image registry Show your employee card to enter the chain store backend
docker push name Push the image to a remote registry Send the recipe to HQ's central repository
docker pull name Pull an image from a registry Fetch a recipe from HQ
docker run name Run a container Make a cup following the recipe and hand it to you
docker images List local images Check inventory

Conclusion

Returning to the opening analogy:

A Dockerfile is Mixue Bingcheng's SOP. It writes "how to make a runnable todo app from scratch" as instructions that a person (engine) can follow: FROM choose environment → COPY copy code → RUN install dependencies → CMD start. After building into an image, push/pull completes the standardized publication.

One closing sentence:

A Dockerfile is one of the standard ways to publish a project—turning "it works on my machine" into "it works everywhere."

A question for you: the backend we demonstrated runs directly with node dist/main.js. If you also want to place the backend inside the container network behind the nginx reverse proxy (rather than direct port exposure), should proxy_pass point to localhost or something else? This connects right to the Docker networking topic from the previous article—feel free to discuss in the comments.