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:
FROMstarts it,COPY/RUNdo the middle work,CMDfinishes 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.