Back to campField Journalby Zin
All entries

docker · devops · mern

Docker: Zero to Hero (Learning by Building a MERN Stack App)

A beginner-friendly Docker guide: images, containers, volumes, networks and Compose, explained with everyday analogies, then a full MERN Todo app (MongoDB, Express, React, Node.js) built, run, hot-reloaded, debugged and deployed with Docker.

28 min read0 likes

Docker: Zero to Hero (MERN Stack App တစ်ခု တည်ဆောက်ရင်း လေ့လာမယ်)

Docker ကို လုံးဝမသိသေးသူတွေအတွက် လမ်းညွှန် — Image၊ Container၊ Volume၊ Network နဲ့ Compose ကို နေ့စဉ်ဘဝ ဥပမာတွေနဲ့ ရှင်းပြပြီး MERN Todo app (MongoDB၊ Express၊ React၊ Node.js) တစ်ခုလုံးကို Docker နဲ့ build၊ run၊ hot reload၊ debug လုပ်ပြီး deploy အထိ လုပ်ကြည့်ပါမယ်။

ဖတ်ချိန် 26 မိနစ်0 likes

You wrote a Node.js API. It runs perfectly on your laptop. You send it to a teammate, and five minutes later you get a message: “It doesn't work on my machine.” Their Node.js is a different version, their MongoDB isn't running, and an environment variable is missing. You spend the afternoon on a video call fixing their setup instead of writing code.

Docker exists to end that afternoon. In this guide we start from zero: no Docker knowledge needed, only some JavaScript, Node.js, React and a few terminal commands. We learn each idea with a real-world analogy, then build and run a complete MERN app (MongoDB, Express, React, Node.js) in containers, the way teams run it in production.

The “it works on my machine” problem

An app is never just your code. It also needs a runtime (Node.js 22, not 18), libraries (the exact versions in node_modules), services (a MongoDB server), settings (environment variables) and an operating system underneath it all. Your laptop has one combination of these. Your teammate's laptop, the test server and the production server each have a different one.

When any piece differs, things break in confusing ways. A library works on macOS but not on Linux. A feature uses a Node.js API that the server's older Node.js doesn't have. Someone forgot to install MongoDB. None of these are bugs in your code, but you still have to fix them.

Docker's idea is simple: ship the environment together with the code. Instead of sending “my code, plus a page of setup instructions,” you send one package that already contains the right Node.js, the right libraries and the right settings. If it runs on your machine, it runs the same way everywhere Docker is installed.

What is Docker? The shipping container idea

Before the 1950s, ships were loaded by hand. Sacks of coffee, barrels of oil, cars and pianos all had different shapes. Every port needed different tools, and loading a ship took weeks. Then came the standard shipping container: one steel box with one size. Cranes, trucks, trains and ships only need to know how to move the box, not what is inside it.

Docker does the same for software. A container is a standard box for an app. Inside: your code, its runtime, its libraries and its settings. Outside: a standard shape that any machine running Docker knows how to start, stop, copy and connect. Your laptop, a cloud server and a CI pipeline are all just different “ships” for the same box.

Technically, Docker is a platform that builds these boxes (images), runs them (containers) and shares them (registries). On Linux it uses kernel features (namespaces and cgroups) to give each container its own isolated view of files, processes and network, while sharing the host's kernel. On macOS and Windows, Docker Desktop runs a small Linux virtual machine for you in the background.

Virtual machines vs containers

You may have used a virtual machine (VM) before, for example VirtualBox running Ubuntu on Windows. A VM and a container both isolate an app, but in very different ways.

Analogy: a VM is a separate house. It has its own foundation, plumbing and electricity (a full operating system with its own kernel). A container is an apartment in a building. Each apartment has its own locked door and furniture, but they all share the building's foundation and plumbing (the host's kernel). Apartments are much cheaper and faster to set up than houses.

  • What it contains: VM: a full guest OS plus the app. Container: only the app and its libraries.
  • Size: VM: usually several GB. Container: often 50–200 MB (node:22-alpine is about 60 MB).
  • Start time: VM: tens of seconds to minutes (it boots an OS). Container: usually under a second (it starts a process).
  • Isolation: VM: very strong, separate kernels. Container: strong, but the kernel is shared.
  • Density: VM: a few per machine. Container: dozens or hundreds per machine.
  • Best for: VM: running a different OS, or strict security boundaries. Container: packaging and shipping apps, microservices, CI.

The core concepts

Docker has seven words you will use every day. Learn these, and every Docker command starts to make sense.

Dockerfile: the recipe

Analogy: a recipe card. It lists the ingredients and the steps, in order: “start with this base, add these files, run this command.” A Dockerfile is a plain text file of instructions that describes how to build an image.

dockerfile
FROM node:22-alpineWORKDIR /appCOPY . .CMD ["node", "index.js"]

Line by line

  1. Start from an official image that already has Node.js 22 on a tiny Alpine Linux.
  2. The command to run when a container starts from this image.

Four lines: start from Node.js 22, work in /app, copy the project in, and run index.js when the container starts. We will write real Dockerfiles later.

Image: the frozen meal

Analogy: a frozen, ready-made meal. The recipe (Dockerfile) was cooked once, and the result was frozen. An image is a read-only snapshot of a filesystem plus some settings: the OS files, Node.js, your code, your node_modules and the start command. You can't change an image; you build a new one.

bash
docker build -t my-app:1.0 .   # build an image from the Dockerfile in this folderdocker images                    # list the images on your machine

Line by line

  1. -t gives the image a name and a tag (name:tag). The tag is usually a version. If you leave it out, Docker uses latest. The . at the end is the build context: the folder Docker may copy files from.

docker build cooks the recipe into an image named my-app with the tag 1.0. docker images shows every image you have, with its size.

Container: the meal on the plate

Analogy: a meal heated and served from the frozen pack. You can serve the same frozen meal on many plates. A container is a running instance of an image: a real process with its own writable layer on top of the read-only image. One image can start many containers.

bash
docker run -d --name app1 -p 3000:3000 my-app:1.0   # start a container in the backgrounddocker ps                                           # list running containers

Line by line

  1. -d runs it in the background (detached). --name gives it a friendly name. -p 3000:3000 connects port 3000 on your computer to port 3000 inside the container.

Delete the container and anything written inside it is gone, but the image is untouched. That is why we will need volumes for the database.

Registry: the supermarket

Analogy: a supermarket freezer full of meals that anyone can take home. A registry stores and shares images. Docker Hub is the default public one; it hosts the official node, mongo and nginx images. Companies also use private registries such as GitHub Container Registry, AWS ECR or Google Artifact Registry.

bash
docker pull mongo:7                 # download an image from Docker Hubdocker push yourname/my-app:1.0     # upload your own image (after docker login)

pull takes an image from the registry; push puts yours there, so a server can pull it later.

Volume: the external hard drive

Analogy: an external hard drive. You can unplug it from one laptop and plug it into a new one, and the files are still there. A volume is storage managed by Docker that lives outside any container. Delete the container, start a new one with the same volume, and your data is still there. Databases always need one.

bash
docker volume create mongo-datadocker run -d --name db -v mongo-data:/data/db mongo:7

Line by line

  1. -v VOLUME:PATH mounts the volume at /data/db, where MongoDB keeps its files. The data now outlives the container.

There is a second kind of mount, the bind mount (-v ./src:/app/src), which shares a folder from your computer with the container. We will use it for hot reload in development.

Network: the office phone system

Analogy: an office's internal phone system. Colleagues call each other by name (“call Mongo”) instead of dialling a full outside number. Containers on the same Docker network can reach each other by name, and Docker's built-in DNS turns that name into the right IP address.

bash
docker network create app-netdocker run -d --name mongo --network app-net mongo:7docker run -d --name api --network app-net -e MONGO_URI=mongodb://mongo:27017/todos my-app:1.0

Line by line

  1. The API reaches the database at the hostname mongo, the other container's name. Not localhost.

Both containers join app-net, so the API can connect to mongo:27017. Containers outside the network can't reach it at all.

Docker Compose: the orchestra conductor

Analogy: an orchestra conductor. Each musician (container) can play alone, but the conductor starts them in the right order and keeps them together. Docker Compose describes your whole app (every container, network and volume) in one YAML file, then starts everything with one command.

yaml
services:  mongo:    image: mongo:7    volumes:      - mongo-data:/data/db  api:    build: .    environment:      MONGO_URI: mongodb://mongo:27017/todos    ports:      - "3000:3000"volumes:  mongo-data:

Line by line

  1. Each entry under services becomes one or more containers. Its key (mongo, api) is also its hostname on the network Compose creates.
  2. image uses a ready-made image; build builds one from a Dockerfile in that folder.

The three docker run commands from above become one readable file. docker compose up starts it all; docker compose down stops it all.

Install Docker and run your first containers

Installing

  • macOS: install Docker Desktop from docker.com (choose Apple Silicon or Intel), open it once, and wait for the whale icon in the menu bar to stop animating.
  • Windows 10/11: install Docker Desktop. It uses WSL 2; accept the prompt to enable it. Keep your projects inside the WSL filesystem (for example ~/projects in Ubuntu) for much faster file access.
  • Linux (Ubuntu, Debian, etc.): install Docker Engine with Docker's convenience script, then let your user run Docker without sudo.
bash
# Linux only: install Docker Engine + the Compose plugincurl -fsSL https://get.docker.com | shsudo usermod -aG docker $USER   # then log out and log back in # Everyone: check that it worksdocker --versiondocker compose version

Line by line

  1. Adds your user to the docker group, so you can run docker without sudo. It takes effect after you log in again.
  2. Compose v2 is a plugin: the command is docker compose (with a space). The old docker-compose (with a dash) is retired.

If both version commands print a version number, you are ready.

Hello, world

bash
docker run hello-world

This one command does a lot. Docker looks for the hello-world image on your machine, doesn't find it, pulls it from Docker Hub, creates a container from it, runs it (it prints a welcome message), and the container exits when the program ends. A container lives exactly as long as its main process.

A real web server: nginx

bash
docker run -d --name web -p 8080:80 nginx:alpine   # start nginx in the backgrounddocker ps                                          # is it running?curl http://localhost:8080                         # or open it in your browserdocker logs web                                    # what has it printed?docker exec -it web sh                             # open a shell INSIDE the container (type exit to leave)docker stop web                                    # stop itdocker rm web                                      # delete the container (the image stays)

Line by line

  1. Port mapping is always HOST:CONTAINER. nginx listens on port 80 inside the container; you reach it on port 8080 of your computer.
  2. exec runs a command in a running container. -it makes it interactive with a terminal. Alpine images have sh, not bash.
  3. Stopped containers still exist until you remove them. docker ps -a shows them all. docker run --rm deletes the container automatically when it stops.

Open http://localhost:8080 and you will see “Welcome to nginx!”. You just ran a production web server without installing it. When you remove the container, your computer is exactly as clean as before.

Dockerfile instructions and layer caching

These are the instructions you will use in almost every Dockerfile:

  • FROM image:tag: the base image to start from. Every Dockerfile begins with it.
  • WORKDIR /app: the working folder for the instructions that follow (created if missing). Like cd, but permanent.
  • COPY src dest: copy files from your build context into the image. (ADD can also unpack archives and download URLs; prefer COPY unless you need that.)
  • RUN command: run a command at build time, such as npm ci. Its result is saved in the image.
  • ENV KEY=value: set an environment variable for the build and for the running container.
  • ARG NAME=default: a variable that exists only at build time (docker build --build-arg NAME=x).
  • EXPOSE 4000: documents which port the app listens on. It does not publish the port; -p or Compose ports does that.
  • USER node: run the following steps and the container as this user instead of root.
  • CMD ["node", "server.js"]: the default command when a container starts. Only the last CMD counts, and it can be overridden at docker run.
  • ENTRYPOINT [...]: a fixed command; CMD then supplies its default arguments. Useful for images that act like a single tool.
  • HEALTHCHECK CMD ...: a command Docker runs periodically to ask “is this container healthy?”

How layer caching works

Analogy: a stack of transparent sheets. Each instruction in a Dockerfile adds one sheet (a layer) on top of the previous ones. When you rebuild, Docker checks each step from the top: if the instruction and its input files are unchanged, it reuses the saved layer (CACHED). As soon as one step changes, that step and every step after it are rebuilt.

This makes the order of instructions matter a lot. Compare these two Dockerfiles for the same app:

dockerfile
# ❌ Slow: any code change re-runs npm installFROM node:22-alpineWORKDIR /appCOPY . .RUN npm ciCMD ["node", "src/server.js"]

Line by line

  1. Copies everything, including your source code. Edit one line of code and this layer changes, so npm ci below it runs again: a minute of waiting for every small edit.
dockerfile
# ✅ Fast: npm install runs only when the dependencies changeFROM node:22-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .CMD ["node", "src/server.js"]

Line by line

  1. Copy only the dependency list first. These files rarely change.
  2. This expensive layer is reused from the cache until package.json or the lock file changes.
  3. Code changes only rebuild from here down: a build that takes seconds instead of minutes.

The rule of thumb: put the things that change least at the top, and the things that change most at the bottom. Base image, then system packages, then dependencies, then your code.

The project: a MERN Todo app

Time to build something real: a Todo app where you can add, complete and delete tasks. It has three parts, and each one runs in its own container:

  • mongo: the MongoDB 7 database, storing its files in a named volume.
  • backend: an Express 5 + Mongoose API on port 4000.
  • frontend: a React app built with Vite and served by nginx on port 80, which also forwards /api requests to the backend.

Folder structure

text
mern-todo/├── docker-compose.yml        # the whole stack (production-like)├── docker-compose.dev.yml    # development overrides (hot reload)├── .env.example              # sample settings: copy it to .env├── backend/│   ├── Dockerfile│   ├── .dockerignore│   ├── package.json│   └── src/│       ├── server.js         # Express app + MongoDB connection│       ├── models/│       │   └── Todo.js       # Mongoose schema│       └── routes/│           └── todos.js      # CRUD routes└── frontend/    ├── Dockerfile            # multi-stage: build with Node, serve with nginx    ├── .dockerignore    ├── nginx.conf    ├── package.json    ├── vite.config.js    ├── index.html    └── src/        ├── main.jsx        ├── App.jsx        └── index.css

Create these folders now. We will fill in every file in the next sections, in order.

Architecture

text
Your browser     │  http://localhost:8080     │  (port 8080 on your computer → port 80 in the container)     ▼┌──────────────────── Docker network: app-net ────────────────────┐│                                                                 ││  ┌──────────────────┐    /api/*     ┌───────────────┐           ││  │ frontend         │ ────────────► │ backend       │           ││  │ nginx :80        │  backend:4000 │ Express :4000 │           ││  │ React build      │               │ Mongoose      │           ││  └──────────────────┘               └───────┬───────┘           ││                                             │ mongo:27017       ││                                             ▼                   ││                                     ┌───────────────┐           ││                                     │ mongo         │           ││                                     │ MongoDB 7     │           ││                                     └───────┬───────┘           │└─────────────────────────────────────────────┼───────────────────┘                                              ▼                                  volume: mongo-data (/data/db)

Only the frontend is published to your computer (port 8080). The browser loads the React app from nginx and calls /api/todos on the same address. nginx forwards those calls to backend:4000 over the private network, and the backend talks to mongo:27017. The database and the API are never exposed to the outside world. Because the browser only ever talks to one origin, we don't even need CORS.

The backend: Express + Mongoose

package.json

Create backend/package.json:

json
{  "name": "mern-todo-backend",  "version": "1.0.0",  "private": true,  "type": "module",  "scripts": {    "start": "node src/server.js",    "dev": "node --watch src/server.js"  },  "dependencies": {    "express": "^5.1.0",    "mongoose": "^9.2.0"  }}

Line by line

  1. Lets us use import / export syntax in Node.js.
  2. node --watch restarts the server whenever a file changes. It is built into Node.js, so we don't need nodemon.

Only two dependencies: Express for the HTTP server and Mongoose for MongoDB. Now run npm install inside backend/ once. It creates package-lock.json, which the Dockerfile needs (more on that below).

The Todo model

Create backend/src/models/Todo.js:

js
import mongoose from 'mongoose'; // The shape of one todo document in MongoDB.const todoSchema = new mongoose.Schema(  {    title: {      type: String,      required: [true, 'Title is required'], // Mongoose rejects a todo without a title      trim: true,      maxlength: 200,    },    completed: { type: Boolean, default: false },  },  { timestamps: true }, // adds createdAt and updatedAt automatically); // Collection name becomes "todos" (lowercased and pluralised).export default mongoose.model('Todo', todoSchema);

Line by line

  1. Saving a todo without a title fails with a ValidationError, which our error handler turns into a 400 response.
  2. Mongoose fills createdAt and updatedAt for us; we sort by createdAt.

A schema describes what one todo looks like: a required title of up to 200 characters, and a completed flag that starts as false.

The routes

Create backend/src/routes/todos.js:

js
import { Router } from 'express';import Todo from '../models/Todo.js'; const router = Router(); // GET /api/todos: every todo, newest firstrouter.get('/', async (req, res) => {  const todos = await Todo.find().sort({ createdAt: -1 });  res.json(todos);}); // POST /api/todos: create one from { "title": "..." }router.post('/', async (req, res) => {  const todo = await Todo.create({ title: req.body?.title });  res.status(201).json(todo); // 201 = Created}); // PATCH /api/todos/:id: change the title and/or completedrouter.patch('/:id', async (req, res) => {  const { title, completed } = req.body ?? {};  const todo = await Todo.findByIdAndUpdate(    req.params.id,    { title, completed }, // undefined fields are left unchanged    { returnDocument: 'after', runValidators: true }, // return the updated todo, and validate it  );  if (!todo) return res.status(404).json({ error: 'Todo not found' });  res.json(todo);}); // DELETE /api/todos/:idrouter.delete('/:id', async (req, res) => {  const todo = await Todo.findByIdAndDelete(req.params.id);  if (!todo) return res.status(404).json({ error: 'Todo not found' });  res.status(204).end(); // 204 = done, nothing to send back}); export default router;

Line by line

  1. In Express 5, req.body is undefined when a request has no JSON body. The ?. stops that from crashing; Mongoose then reports the missing title.
  2. Return the todo after the update, and run the schema's validators on it. (new: true does the same but is deprecated in Mongoose 9.)
  3. 204 means “done, nothing to send back”, the usual reply to a successful delete.

Four routes give us full CRUD: list, create, update (complete / rename) and delete. A wrong id returns 404, so the frontend can show a clear message.

The server

Create backend/src/server.js:

js
import express from 'express';import mongoose from 'mongoose';import todosRouter from './routes/todos.js'; // Settings come from environment variables, so the same image runs anywhere.const PORT = process.env.PORT || 4000;const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/todos'; const app = express();app.use(express.json()); // parse JSON request bodies into req.body // Health check: Docker asks this URL "are you OK?"app.get('/api/health', (req, res) => {  const dbReady = mongoose.connection.readyState === 1; // 1 = connected  res.status(dbReady ? 200 : 503).json({ status: dbReady ? 'ok' : 'database not ready' });}); app.use('/api/todos', todosRouter); // One place for every error. Express 5 sends errors from async routes here too.app.use((err, req, res, next) => {  const badInput = err.name === 'ValidationError' || err.name === 'CastError';  if (!badInput) console.error(err);  res.status(badInput ? 400 : 500).json({ error: badInput ? err.message : 'Something went wrong' });}); async function start() {  await mongoose.connect(MONGO_URI);  console.log('Connected to MongoDB');   const server = app.listen(PORT, () => console.log(`API listening on port ${PORT}`));   // `docker stop` sends SIGTERM: finish open requests, close the DB, then exit.  const shutdown = (signal) => {    console.log(`${signal} received, shutting down`);    server.close(async () => {      await mongoose.disconnect();      process.exit(0);    });  };  process.on('SIGTERM', shutdown);  process.on('SIGINT', shutdown);} start().catch((err) => {  console.error('Failed to start:', err.message);  process.exit(1); // a non-zero exit lets Docker's restart policy try again});

Line by line

  1. The connection string comes from an environment variable. The same image can connect to a local MongoDB, a container or MongoDB Atlas without any code change. The default is for running without Docker.
  2. A health endpoint. Docker calls it to check whether the API is really working (connected to the database), not just whether the process is alive.
  3. Express 5 forwards errors thrown in async routes to this handler automatically. Bad input becomes 400; anything else becomes 500 without leaking details.
  4. docker stop sends SIGTERM. We stop accepting requests, close the database connection and exit cleanly, instead of being killed mid-request after 10 seconds.
  5. If MongoDB can't be reached at startup, the process exits with an error code, and the restart: unless-stopped policy in Compose tries again.

The server connects to MongoDB first and only then starts listening, so it never accepts requests it can't serve. Everything that differs between environments (port, database address) comes from environment variables. That is the key to one image that runs anywhere.

The backend Dockerfile

Create backend/Dockerfile:

dockerfile
# 1. Start from a small official image that already has Node.js 22FROM node:22-alpine # 2. Run in production mode (some libraries skip dev-only work)ENV NODE_ENV=production # 3. Every command below runs inside /app in the imageWORKDIR /app # 4. Copy ONLY the package files first, so this layer stays cached#    until the dependencies changeCOPY package.json package-lock.json ./ # 5. Install exactly what the lock file says, without devDependenciesRUN npm ci --omit=dev # 6. Now copy the source code (changes often, so it comes last)COPY src ./src # 7. Don't run as root: the node image ships a user called "node"USER node # 8. Documents the port the app listens on (it does not publish it)EXPOSE 4000 # 9. The command that runs when a container startsCMD ["node", "src/server.js"]

Line by line

  1. alpine images are tiny (about 60 MB for Node.js), which means faster downloads and fewer packages that could have security holes.
  2. The layer-caching trick from earlier: dependencies first, code later.
  3. npm ci installs exactly what the lock file lists, which makes builds repeatable. It fails if package-lock.json is missing, which is why we ran npm install once. --omit=dev leaves out devDependencies.
  4. Running as root inside a container is a needless risk. The official Node.js images include a node user for this.
  5. Exec form, so Node.js receives SIGTERM directly and our graceful shutdown runs.

This is the production image for the API: Node.js 22, production dependencies only, our source code, running as a normal user.

The .dockerignore

Create backend/.dockerignore:

dotenv
node_modulesnpm-debug.log.env.gitDockerfile.dockerignore

Line by line

  1. The most important line. Your local node_modules may contain packages built for macOS or Windows; they must never be copied into a Linux image. The image installs its own.
  2. Never bake secrets into an image. Anyone who pulls the image could read them.

.dockerignore works like .gitignore, but for the build context. It keeps builds fast (less to send to Docker) and images clean.

The frontend: React + Vite, served by nginx

package.json and Vite config

Create frontend/package.json:

json
{  "name": "mern-todo-frontend",  "version": "1.0.0",  "private": true,  "type": "module",  "scripts": {    "dev": "vite",    "build": "vite build",    "preview": "vite preview"  },  "dependencies": {    "react": "^19.2.0",    "react-dom": "^19.2.0"  },  "devDependencies": {    "@vitejs/plugin-react": "^6.0.0",    "vite": "^8.0.0"  }}

Line by line

  1. vite build turns the React code into plain static files (HTML, CSS, JS) in dist/. Those are all nginx needs.

Then create frontend/vite.config.js:

js
import { defineConfig } from 'vite';import react from '@vitejs/plugin-react'; export default defineConfig({  plugins: [react()],  server: {    host: true, // listen on 0.0.0.0, so the dev server is reachable from outside its container    port: 5173,    // In development, Vite forwards /api to the backend.    // On your machine that is localhost:4000; inside Compose it is http://backend:4000.    proxy: {      '/api': process.env.API_URL || 'http://localhost:4000',    },    // Some setups (Windows files mounted into a container) miss file-change events: poll instead.    watch: { usePolling: process.env.WATCH_POLLING === 'true' },  },});

Line by line

  1. By default Vite only listens on localhost inside its own container, so your browser can't reach it. host: true makes it listen on every network interface.
  2. In development the Vite server forwards /api calls to the backend. On your machine that is localhost:4000; in Compose we set API_URL=http://backend:4000: the service name.
  3. An escape hatch for file systems that don't send change events into containers (mainly Windows folders outside WSL).

Run npm install in frontend/ once, for the same reason as the backend: it creates package-lock.json.

The React code

Create frontend/index.html:

html
<!doctype html><html lang="en">  <head>    <meta charset="UTF-8" />    <meta name="viewport" content="width=device-width, initial-scale=1.0" />    <title>MERN Todo</title>  </head>  <body>    <div id="root"></div>    <script type="module" src="/src/main.jsx"></script>  </body></html>

Create frontend/src/main.jsx:

jsx
import { StrictMode } from 'react';import { createRoot } from 'react-dom/client';import App from './App.jsx';import './index.css'; createRoot(document.getElementById('root')).render(  <StrictMode>    <App />  </StrictMode>,);

These two files are standard Vite boilerplate: an HTML page with a root div, and the script that mounts our App component into it.

Create frontend/src/App.jsx:

jsx
import { useEffect, useState } from 'react'; // A relative URL: the browser sends it to whoever served the page// (Vite in development, nginx in production), and they forward it to the backend.const API = '/api/todos'; async function request(url, options = {}) {  const res = await fetch(url, {    headers: { 'Content-Type': 'application/json' },    ...options,  });  if (!res.ok) {    const body = await res.json().catch(() => ({}));    throw new Error(body.error || `Request failed (${res.status})`);  }  return res.status === 204 ? null : res.json();} export default function App() {  const [todos, setTodos] = useState([]);  const [title, setTitle] = useState('');  const [loading, setLoading] = useState(true);  const [error, setError] = useState('');   // Load the list once, when the page opens  useEffect(() => {    request(API)      .then(setTodos)      .catch((err) => setError(err.message))      .finally(() => setLoading(false));  }, []);   async function addTodo(event) {    event.preventDefault();    if (!title.trim()) return;    try {      const todo = await request(API, { method: 'POST', body: JSON.stringify({ title }) });      setTodos((list) => [todo, ...list]);      setTitle('');      setError('');    } catch (err) {      setError(err.message);    }  }   async function toggleTodo(todo) {    try {      const updated = await request(`${API}/${todo._id}`, {        method: 'PATCH',        body: JSON.stringify({ completed: !todo.completed }),      });      setTodos((list) => list.map((t) => (t._id === updated._id ? updated : t)));    } catch (err) {      setError(err.message);    }  }   async function deleteTodo(id) {    try {      await request(`${API}/${id}`, { method: 'DELETE' });      setTodos((list) => list.filter((t) => t._id !== id));    } catch (err) {      setError(err.message);    }  }   return (    <main className="card">      <h1>🐳 MERN Todo</h1>       <form onSubmit={addTodo}>        <input          type="text"          value={title}          onChange={(e) => setTitle(e.target.value)}          placeholder="What needs doing?"          maxLength={200}        />        <button type="submit">Add</button>      </form>       {error && <p className="error">⚠️ {error}</p>}      {loading && <p className="muted">Loading…</p>}      {!loading && todos.length === 0 && <p className="muted">Nothing to do yet. Add one above!</p>}       <ul>        {todos.map((todo) => (          <li key={todo._id} className={todo.completed ? 'done' : ''}>            <input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo)} />            <span>{todo.title}</span>            <button className="delete" onClick={() => deleteTodo(todo._id)} aria-label="Delete">              ✕            </button>          </li>        ))}      </ul>    </main>  );}

Line by line

  1. A relative URL, with no host. In development Vite answers it; in production nginx does. Both forward it to the backend, so the React code never needs to know where the backend lives.
  2. fetch does not throw on 400 or 500 responses. We check res.ok ourselves and show the API's error message.
  3. Load the list once when the page opens, and show “Loading…” until it arrives.
  4. Add the new todo at the top of the list using the object the API returns, so it has a real _id.

A small but complete React app: it loads todos, adds new ones, toggles them done, deletes them, and shows errors instead of failing silently.

Create frontend/src/index.css for a clean dark look:

css
* { box-sizing: border-box; }body {  margin: 0;  font-family: system-ui, sans-serif;  background: #0f172a;  color: #e2e8f0;  display: grid;  place-items: start center;  min-height: 100vh;  padding: 48px 16px;}.card { width: 100%; max-width: 480px; background: #1e293b; border-radius: 12px; padding: 24px; }h1 { margin: 0 0 16px; font-size: 1.5rem; }form { display: flex; gap: 8px; margin-bottom: 16px; }input[type='text'] { flex: 1; padding: 10px 12px; border-radius: 8px; border: 1px solid #334155; background: #0f172a; color: inherit; }button { padding: 10px 14px; border: 0; border-radius: 8px; background: #2496ed; color: white; cursor: pointer; }button.delete { background: transparent; color: #f87171; padding: 4px 8px; }ul { list-style: none; margin: 0; padding: 0; }li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid #334155; }li span { flex: 1; }li.done span { text-decoration: line-through; color: #64748b; }.error { color: #f87171; }.muted { color: #64748b; }

nginx.conf

Create frontend/nginx.conf:

nginx
server {    listen 80;    server_name _;     root /usr/share/nginx/html;    index index.html;     # Docker's built-in DNS server. Asking it on each request means nginx    # still finds the backend after that container is recreated.    resolver 127.0.0.11 valid=10s;     # Everything under /api goes to the backend container.    location /api/ {        # "backend" is the Compose SERVICE NAME, not localhost!        set $backend http://backend:4000;        proxy_pass $backend;         proxy_http_version 1.1;        proxy_set_header Host $host;        proxy_set_header X-Real-IP $remote_addr;        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;        proxy_set_header X-Forwarded-Proto $scheme;    }     # Vite puts a content hash in these file names, so browsers may cache them for a year.    location /assets/ {        add_header Cache-Control "public, max-age=31536000, immutable";    }     # Any other path returns index.html, so a React route survives a page refresh.    location / {        try_files $uri $uri/ /index.html;    }}

Line by line

  1. 127.0.0.11 is Docker's built-in DNS server, inside every container on a user-defined network. With it, nginx looks up backend when requests arrive, so it keeps working after the backend container is recreated with a new IP.
  2. backend is the Compose service name. Writing localhost:4000 here would send requests to the nginx container itself, and you would get 502 Bad Gateway.
  3. Vite puts a hash in each asset's file name (index-BSuVuEZq.js), so a changed file always gets a new name. Browsers can cache them for a year.
  4. For a single-page app: if no file matches the path, return index.html and let React handle the route. Without this, refreshing /about gives a 404.

nginx does two jobs: it serves the static React files, and it acts as a reverse proxy, passing every /api/ request to the backend container.

The multi-stage Dockerfile

Create frontend/Dockerfile:

dockerfile
# ---------- Stage 1: install dependencies ----------FROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci # ---------- Stage 2: build the static site ----------FROM deps AS buildCOPY . .# Vite writes plain HTML, CSS and JS into /app/distRUN npm run build # ---------- Stage 3: serve it with nginx ----------FROM nginx:alpine AS production# Our server settings replace nginx's default siteCOPY nginx.conf /etc/nginx/conf.d/default.conf# Take ONLY the built files from stage 2: no Node.js, no node_modulesCOPY --from=build /app/dist /usr/share/nginx/htmlEXPOSE 80# The nginx image already has the right CMD, so we don't repeat it

Line by line

  1. Stage 1 installs all dependencies, including Vite (a devDependency), because we need it to build. We name the stage so we can reuse it in development mode later.
  2. Stage 2 starts from stage 1, adds the source code and builds the static files.
  3. Stage 3 starts fresh from a tiny nginx image. Everything from the previous stages is left behind unless we copy it.
  4. Copy only dist/ out of the build stage. The final image has no Node.js and no node_modules.

Analogy for multi-stage builds: a restaurant kitchen and its dining room. All the knives, pans and mess (Node.js, Vite, hundreds of packages) stay in the kitchen. Only the finished plate (the static files) goes out to the dining room (nginx). The result: a frontend image of about 50 MB instead of well over 200 MB.

Finally, create frontend/.dockerignore:

dotenv
node_modulesdistnpm-debug.log.env.gitDockerfile.dockerignore

Line by line

  1. Also ignore any dist/ you built locally; the image builds its own.

docker-compose.yml: the whole stack in one file

First create the settings file. Copy .env.example to .env (cp .env.example .env) and change the password:

dotenv
# Copy this file to .env and change the values.# Use letters and numbers only in the password (no @ : / ?), because it goes into a URL.MONGO_USER=todo_adminMONGO_PASSWORD=change_me_123

Line by line

  1. The password goes inside a MongoDB connection URL, where @, :, / and ? have special meanings. Keep it simple, or URL-encode it.

Compose automatically reads a file named .env next to docker-compose.yml and substitutes ${MONGO_USER} and ${MONGO_PASSWORD} in the YAML. Add .env to your .gitignore; commit only .env.example.

Now the main file. Create docker-compose.yml in the project root:

yaml
services:  # ---------- MongoDB ----------  mongo:    image: mongo:7    restart: unless-stopped    environment:      # Creates the admin user the FIRST time the volume is empty      MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:?set MONGO_USER in .env}      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:?set MONGO_PASSWORD in .env}    volumes:      - mongo-data:/data/db   # the database files live in a named volume    networks:      - app-net    healthcheck:      # "Can I ping the database?" Exit code 0 means healthy.      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]      interval: 10s      timeout: 5s      retries: 5      start_period: 20s    # No "ports:" on purpose: only containers on app-net can reach MongoDB.   # ---------- Express API ----------  backend:    build: ./backend    restart: unless-stopped    environment:      PORT: 4000      # "mongo" is the service name above. NOT localhost!      MONGO_URI: mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongo:27017/todos?authSource=admin    depends_on:      mongo:        condition: service_healthy   # wait until the healthcheck passes    networks:      - app-net    healthcheck:      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4000/api/health"]      interval: 10s      timeout: 5s      retries: 5      start_period: 10s   # ---------- React app served by nginx ----------  frontend:    build: ./frontend    restart: unless-stopped    ports:      - "8080:80"   # HOST:CONTAINER  ->  open http://localhost:8080    depends_on:      backend:        condition: service_healthy    networks:      - app-net networks:  app-net:    driver: bridge volumes:  mongo-data:

Line by line

  1. The official MongoDB 7 image. No Dockerfile needed for the database.
  2. If a container crashes (or the server reboots), Docker starts it again, unless you stopped it yourself.
  3. ${VAR:?message} makes Compose stop with that message if the variable is missing, instead of silently starting MongoDB with an empty password.
  4. The named volume keeps the database files. Containers come and go; the data stays.
  5. The healthcheck pings MongoDB with the mongosh tool that ships in the image. ping doesn't need a login, so this works even with authentication on.
  6. MongoDB needs a few seconds to start (longer the first time, while it creates the user). Failures during this window don't count.
  7. Build the image from backend/Dockerfile instead of downloading one.
  8. The host is mongo, the service name. authSource=admin tells MongoDB the user was created in the admin database, which is where MONGO_INITDB_ROOT_* puts it.
  9. Plain depends_on only waits until the mongo container starts. With service_healthy, the backend waits until MongoDB answers its healthcheck.
  10. Alpine images include wget (curl is not installed). Inside its own container, 127.0.0.1 correctly means the backend itself.
  11. The only published port in the whole stack: HOST:CONTAINER. Open http://localhost:8080.
  12. A user-defined bridge network with built-in DNS, so services find each other by name. (Compose would create a default network anyway; naming it makes the idea visible.)
  13. Declaring the volume here makes Compose create and manage it. Its real name will be <project>_mongo-data.

This one file is the whole architecture diagram in code: three services, the order they start in, how they find each other, which port is public, and where the data lives.

Run it and test it

Start the stack

bash
cd mern-todocp .env.example .env                 # then edit the password(cd backend && npm install)          # creates backend/package-lock.json(cd frontend && npm install)         # creates frontend/package-lock.json docker compose up -d --build         # build the images and start everything in the backgrounddocker compose ps                    # all three should be "running", mongo and backend "(healthy)"

Line by line

  1. Only needed once, to create the lock files. After that, Docker installs the dependencies inside the images.
  2. --build rebuilds images whose files changed; -d gives you your terminal back. The first build takes a few minutes; later builds are fast thanks to layer caching.
  3. If a service isn't healthy yet, wait a few seconds and run it again. The frontend starts only after the backend is healthy.

Open http://localhost:8080. Add a few todos, tick one off, delete one. 🎉 You are running a three-container MERN app.

Test the API through nginx

bash
curl http://localhost:8080/api/health# {"status":"ok"} curl -X POST http://localhost:8080/api/todos \  -H "Content-Type: application/json" \  -d '{"title":"Learn Docker volumes"}'# {"title":"Learn Docker volumes","completed":false,"_id":"...", ...} curl http://localhost:8080/api/todos# [ ...every todo, newest first... ] curl -X POST http://localhost:8080/api/todos -H "Content-Type: application/json" -d '{}'# {"error":"Todo validation failed: title: Title is required"}   (HTTP 400)

Line by line

  1. From your computer, localhost:8080 is correct: it is the published port of the frontend container. nginx passes /api/... on to the backend.

These requests take the same path as the browser: your computer → nginx → backend → MongoDB. The last one shows that validation errors come back as a clear 400, not a crash.

Read the logs

bash
docker compose logs backend          # everything the backend printeddocker compose logs -f backend       # follow live (Ctrl+C to stop following)docker compose logs --tail 50 mongo  # only the last 50 lines

You should see Connected to MongoDB and API listening on port 4000. When anything goes wrong, the logs are always the first place to look.

Prove the data persists

bash
docker compose down        # stop and DELETE all three containers (the volume stays)docker compose up -d       # brand-new containers# reload http://localhost:8080: your todos are still there ✅ docker volume ls           # you'll see mern-todo_mongo-data # Look inside the database directly (use the values from your .env)docker compose exec mongo mongosh -u todo_admin -p change_me_123 \  --authenticationDatabase admin todos --eval "db.todos.find()"

Line by line

  1. down removes the containers and the network, but not named volumes. MongoDB's files survive in mongo-data.
  2. Runs mongosh inside the running mongo container, so you can query the real data.

This is the volume doing its job: the containers were destroyed and recreated, but the data lived on the “external hard drive”.

Development mode with hot reload

Rebuilding images after every code change is too slow for daily work. In development we want to edit a file and see the result immediately. The tool for that is the bind mount: it shares a folder on your computer with the container, live, in both directions.

Analogy: a shared Google Doc instead of emailing a file back and forth. You type on your computer, and the container sees the change at the same moment.

Create docker-compose.dev.yml next to the main file. It only lists what is different in development:

yaml
# Development overrides. Use together with docker-compose.yml:#   docker compose -f docker-compose.yml -f docker-compose.dev.yml up --buildservices:  mongo:    ports:      - "27017:27017"   # lets MongoDB Compass on your machine connect (dev only)   backend:    command: ["node", "--watch", "src/server.js"]   # restart when a file changes    environment:      NODE_ENV: development    volumes:      - ./backend/src:/app/src   # bind mount: your code, live inside the container    ports:      - "4000:4000"   # call the API directly with curl while developing   frontend:    build:      context: ./frontend      target: deps   # stop at the stage that has Node and node_modules    command: ["npm", "run", "dev"]    environment:      API_URL: http://backend:4000   # Vite's proxy target: the service name again      WATCH_POLLING: "false"         # set to "true" if edits don't trigger a reload    volumes:      - ./frontend:/app          # your code, live      - /app/node_modules        # ...but keep the container's own node_modules    ports: !override  # replace the 8080:80 list instead of adding to it      - "5173:5173"   # open http://localhost:5173

Line by line

  1. Publishes MongoDB to your computer in development only, so a GUI such as MongoDB Compass can connect to mongodb://todo_admin:...@localhost:27017/?authSource=admin.
  2. Overrides the image's CMD: Node.js restarts the server whenever a file in src/ changes.
  3. The bind mount: the container's /app/src is your backend/src folder. Save a file and the backend restarts within a second.
  4. Build only up to the deps stage of the multi-stage Dockerfile: Node.js plus node_modules, with no nginx.
  5. Run the Vite dev server instead of nginx.
  6. Vite's proxy target, read by vite.config.js. Again: the service name.
  7. Your whole frontend/ folder, live inside the container.
  8. An anonymous volume over node_modules. Without it, the bind mount above would hide the container's installed packages behind your local folder (which may be empty, or built for another OS).
  9. Normally Compose adds list items from the second file to the first. !override replaces the frontend's ports, so only 5173 is published in development.

Start the stack with both files. The later file overrides the earlier one:

bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build # open http://localhost:5173# edit frontend/src/App.jsx  -> the page updates instantly (Vite HMR)# edit backend/src/server.js -> the backend logs "restarting" and picks up the change

Now you develop exactly as before, with instant feedback, but every service still runs in the same Linux containers as production. New teammates only need Docker and one command.

Command cheat sheet

bash
# ---------- Images ----------docker build -t name:tag .          # build an image from ./Dockerfiledocker images                       # list imagesdocker pull mongo:7                 # download an imagedocker rmi name:tag                 # delete an imagedocker history name:tag             # show its layers # ---------- Containers ----------docker run -d --name x -p 8080:80 image   # start a container in the backgrounddocker run --rm -it node:22-alpine sh     # throwaway interactive containerdocker ps                           # running containersdocker ps -a                        # all containers, including stoppeddocker logs -f x                    # follow a container's outputdocker exec -it x sh                # shell inside a running containerdocker stop x && docker rm x        # stop, then deletedocker inspect x                    # every detail, as JSONdocker stats                        # live CPU and memory use # ---------- Volumes & networks ----------docker volume ls                    # list volumesdocker volume rm name               # delete a volume (and its data!)docker network ls                   # list networksdocker network inspect name         # who is connected? # ---------- Compose (run next to docker-compose.yml) ----------docker compose up -d --build        # build and start everythingdocker compose ps                   # status and healthdocker compose logs -f backend      # follow one service's logsdocker compose exec backend sh      # shell inside a servicedocker compose restart backend      # restart one servicedocker compose up -d --build backend   # rebuild and restart one servicedocker compose down                 # stop and remove containers (keep volumes)docker compose down -v              # ...and delete volumes too (data loss!)docker compose config               # print the final merged config (great for debugging) # ---------- Cleaning up ----------docker system df                    # how much disk Docker usesdocker system prune                 # remove stopped containers, unused networks, dangling imagesdocker image prune -a               # remove every image not used by a container

Keep this list nearby. docker compose config deserves a special mention: it prints your Compose file after variables are filled in and override files are merged. When something behaves strangely, check what Compose actually sees.

Common errors and how to fix them

ECONNREFUSED 127.0.0.1:27017

Cause: the backend is trying to reach MongoDB at localhost (127.0.0.1). Inside a container, that is the backend container itself, where no database is running.

Fix: use the service name: mongodb://...@mongo:27017/todos?authSource=admin. The same goes for any container-to-container address: backend:4000, never localhost:4000.

Bind for 0.0.0.0:8080 failed: port is already allocated

Cause: something on your computer (another container, a local dev server, or a local MongoDB) already uses that port.

Fix: find it with docker ps or lsof -i :8080 (macOS/Linux) and stop it, or change only the host side of the mapping: "8081:80". On macOS, port 5000 is taken by the AirPlay Receiver, which is one reason this project uses 4000.

Cannot connect to the Docker daemon

Cause: Docker isn't running, or (on Linux) your user isn't in the docker group.

Fix: start Docker Desktop and wait for it to be ready. On Linux, run sudo systemctl start docker, and sudo usermod -aG docker $USER followed by logging out and in.

npm ci: “The npm ci command can only install with an existing package-lock.json”

Cause: the lock file is missing or out of sync with package.json.

Fix: run npm install in that folder (or use the docker run ... npm install --package-lock-only trick), commit the lock file, and rebuild.

MongoServerError: Authentication failed

Cause: MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD are used only the first time, when the volume is empty. If you change the password in .env later, the database still has the old user.

Fix: put the old password back, or (if the data doesn't matter) start fresh with docker compose down -v and docker compose up -d. Also check the password for URL-breaking characters like @ or :.

dependency failed to start: container mern-todo-mongo-1 is unhealthy

Cause: a healthcheck kept failing, so Compose refused to start the services that depend on it.

Fix: read that service's logs: docker compose logs mongo. docker inspect --format "{{json .State.Health}}" mern-todo-mongo-1 shows the last healthcheck results and their output.

502 Bad Gateway from nginx

Cause: nginx is running but can't reach the backend. The backend crashed, is still starting, or the proxy target is wrong (localhost instead of backend).

Fix: docker compose ps and docker compose logs backend. Check that nginx.conf says http://backend:4000 and that the port matches the backend's PORT.

My code changes don't show up

Cause: in production mode, code is copied into the image at build time. Restarting the container runs the old image again.

Fix: rebuild: docker compose up -d --build. In dev mode, check that the bind mounts are in place with docker compose -f docker-compose.yml -f docker-compose.dev.yml config.

exec format error

Cause: the image was built for a different CPU. A typical case: you built it on an Apple Silicon Mac (arm64) and run it on an Intel/AMD server (amd64).

Fix: build for the server's platform: docker buildx build --platform linux/amd64 ..., or build on the server itself.

no space left on device

Cause: old images, stopped containers and build cache pile up quickly.

Fix: docker system df to see the usage, then docker system prune (and docker builder prune for the build cache). Add --volumes only if you are sure no volume holds data you need.

Best practices, Docker Hub and deploying to a VPS

Best practices

  • Pin versions: node:22-alpine, mongo:7. Never rely on latest in production.
  • Small images: use alpine or slim bases and multi-stage builds. Smaller images download faster and have fewer packages to patch.
  • Order for the cache: dependency files first, RUN npm ci, then the code.
  • Always have a .dockerignore, with node_modules, .env and .git in it.
  • Don't run as root: USER node.
  • No secrets in images: pass them at runtime with environment variables, an .env file that is never committed, or Docker secrets.
  • One process per container: the API, the database and the web server each get their own container, so each can be restarted, scaled and logged separately.
  • Healthchecks + depends_on: condition: service_healthy, so services start in the right order.
  • Expose as little as possible: only the public entry point gets ports. Databases stay on the internal network.
  • Log to stdout/stderr (plain console.log), so docker logs and every hosting platform can collect the output.
  • Handle SIGTERM and use exec-form CMD, so deploys don't cut requests off halfway.

Push your images to Docker Hub

Create a free account at hub.docker.com. Your images will be named yourname/image:tag (replace yourname with your Docker Hub username).

bash
docker login                                   # enter your Docker Hub username and an access token # Build for the server's CPU (usually amd64), tag, and push in one stepdocker buildx build --platform linux/amd64 -t yourname/mern-todo-backend:1.0.0 --push ./backenddocker buildx build --platform linux/amd64 -t yourname/mern-todo-frontend:1.0.0 --push ./frontend

Line by line

  1. Use an access token (Account settings → Personal access tokens) instead of your password.
  2. Important if you are on an Apple Silicon Mac: most VPS servers are amd64. You can build both at once with --platform linux/amd64,linux/arm64.

Use a real version number for each release (1.0.0, 1.0.1, ...), so you can always go back to a previous version.

Deploy to a VPS

Any Linux server with 1–2 GB of RAM works (DigitalOcean, Hetzner, Linode, AWS Lightsail, and others). On the server you only need Docker; no Node.js and no MongoDB installation.

bash
# 1. Connect and install Dockerssh root@YOUR_SERVER_IPcurl -fsSL https://get.docker.com | sh # 2. Get the project (Compose file + configs) onto the servergit clone https://github.com/yourname/mern-todo.gitcd mern-todocp .env.example .env && nano .env      # set a STRONG password # 3. Build and start (or use the pushed images, see below)docker compose up -d --builddocker compose ps # 4. Allow only SSH and web traffic through the firewallufw allow OpenSSHufw allow 8080/tcpufw enable

Line by line

  1. The .env file is created on the server and never committed to Git.
  2. For a real site, map the frontend to "80:80" instead and allow port 80 (and 443 for HTTPS).

Your app is now live at http://YOUR_SERVER_IP:8080, exactly as it ran on your laptop. That is the promise of Docker, delivered.

To deploy the images you pushed instead of building on the server, replace each build: line in docker-compose.yml with image: yourname/mern-todo-backend:1.0.0 (and the frontend equivalent). Then a release is just docker compose pull && docker compose up -d.

Conclusion and what to learn next

You started with “it works on my machine” and finished with a full MERN application that runs the same way everywhere. Along the way you learned:

  • The seven core ideas: Dockerfile, image, container, registry, volume, network and Compose.
  • How to write production Dockerfiles with layer caching, multi-stage builds, .dockerignore and a non-root user.
  • How containers find each other by service name, and why localhost inside a container means the container itself.
  • How healthchecks and depends_on start a stack in the right order, and how volumes keep data safe.
  • How to develop with hot reload using bind mounts, debug the most common errors, push to Docker Hub and deploy to a VPS.

Your learning roadmap

  1. Practise on your own projects. Dockerize an app you already have. Breaking things and reading the logs is how this knowledge sticks.
  2. CI/CD: build and push images automatically with GitHub Actions on every push, then deploy with one command.
  3. Image security: scan images with Docker Scout or Trivy, and use Docker secrets for credentials.
  4. Compose Watch and Dev Containers: an even smoother local workflow.
  5. Reverse proxies and HTTPS: Caddy, Traefik or nginx with Let's Encrypt.
  6. Observability: collect logs and metrics with Prometheus, Grafana and Loki.
  7. Orchestration: when one server is no longer enough, learn Kubernetes (start with Minikube or k3s). Pods, Deployments and Services will feel familiar, because they build on the same container ideas.

Docker is one of those skills that pays off in every project you touch afterwards. Build the Todo app, break it on purpose, fix it, and then containerize something of your own. Happy shipping! 🐳

Node.js API တစ်ခု ရေးပြီးပြီ ဆိုပါစို့။ ကိုယ့် laptop ပေါ်မှာ ပြဿနာမရှိ အလုပ်လုပ်ပါတယ်။ Teammate ဆီ ပို့လိုက်တယ်၊ ငါးမိနစ်လောက်အကြာမှာ message တစ်စောင် ရောက်လာတယ် — “ငါ့စက်မှာတော့ အလုပ်မလုပ်ဘူး။” သူ့ Node.js version မတူဘူး၊ MongoDB လည်း run မထားဘူး၊ environment variable တစ်ခုလည်း လိုနေတယ်။ ညနေခင်းတစ်ခုလုံး code ရေးမယ့်အစား video call ပေါ်မှာ သူ့ setup ကို ပြင်ပေးနေရတော့တယ်။

Docker က အဲဒီလို ညနေခင်းတွေ မရှိတော့အောင် ဖြစ်လာတာပါ။ ဒီ guide မှာ သုညကနေ စပါမယ် — Docker အကြောင်း ဘာမှ မသိလည်း ရပါတယ်၊ JavaScript၊ Node.js၊ React နဲ့ terminal command အနည်းငယ် သိရင် လုံလောက်ပါပြီ။ Concept တစ်ခုချင်းကို လက်တွေ့ဘဝ ဥပမာနဲ့ နားလည်အောင် လုပ်ပြီး MERN app (MongoDB၊ Express၊ React၊ Node.js) တစ်ခုလုံးကို production မှာ team တွေ run သလိုမျိုး container တွေထဲမှာ တည်ဆောက်ပြီး run ကြည့်ပါမယ်။

“ငါ့စက်မှာတော့ အလုပ်လုပ်တယ်” ပြဿနာ

App တစ်ခုဆိုတာ ကိုယ့် code သက်သက် မဟုတ်ပါဘူး။ Runtime (Node.js 18 မဟုတ်ဘဲ 22)၊ library တွေ (node_modules ထဲက version အတိအကျ)၊ service တွေ (MongoDB server)၊ setting တွေ (environment variable) နဲ့ အောက်ခြေမှာ operating system လည်း လိုပါတယ်။ ကိုယ့် laptop မှာ ဒီအရာတွေရဲ့ ပေါင်းစပ်မှု တစ်မျိုး ရှိပါတယ်။ Teammate ရဲ့ laptop၊ test server နဲ့ production server တစ်ခုချင်းစီမှာ တခြားတစ်မျိုးစီ ရှိနေပါတယ်။

တစ်ခုခု မတူတာနဲ့ နားလည်ရခက်တဲ့ ပုံစံတွေနဲ့ ပျက်ကုန်ပါတယ်။ Library တစ်ခုက macOS မှာ အလုပ်လုပ်ပေမဲ့ Linux မှာ မလုပ်ဘူး။ Feature တစ်ခုက server ပေါ်က Node.js အဟောင်းမှာ မရှိတဲ့ API ကို သုံးထားတယ်။ တစ်ယောက်ယောက်က MongoDB install လုပ်ဖို့ မေ့သွားတယ်။ ဒါတွေ ဘယ်တစ်ခုမှ ကိုယ့် code ထဲက bug မဟုတ်ပေမဲ့ ကိုယ်ပဲ ပြင်ရပါတယ်။

Docker ရဲ့ အိုင်ဒီယာက ရိုးရှင်းပါတယ် — code နဲ့အတူ environment ကိုပါ တစ်ပါတည်း ပို့လိုက်ပါ။ “ကျွန်တော့် code နဲ့ setup လုပ်နည်း စာတစ်မျက်နှာ” ပို့မယ့်အစား မှန်ကန်တဲ့ Node.js၊ library တွေနဲ့ setting တွေ ပါပြီးသား package တစ်ခုကို ပို့လိုက်တာပါ။ ကိုယ့်စက်မှာ run လို့ရရင် Docker ရှိတဲ့ ဘယ်နေရာမှာမဆို အတူတူ run ပါလိမ့်မယ်။

Docker ဆိုတာ ဘာလဲ? Shipping container အိုင်ဒီယာ

1950 ခုနှစ်တွေ မတိုင်ခင်က သင်္ဘောတွေကို လက်နဲ့ ကုန်တင်ကြပါတယ်။ ကော်ဖီအိတ်၊ ရေနံစည်၊ ကားနဲ့ စန္ဒယားတွေ ပုံသဏ္ဌာန် အမျိုးမျိုးပါ။ ဆိပ်ကမ်းတိုင်းမှာ ကိရိယာ မတူတာတွေ လိုပြီး သင်္ဘောတစ်စင်း ကုန်တင်ဖို့ ရက်သတ္တပတ်ချီ ကြာပါတယ်။ ပြီးတော့ standard shipping container ပေါ်လာပါတယ် — အရွယ်အစား တစ်မျိုးတည်းရှိတဲ့ သံသေတ္တာကြီး။ Crane၊ ကုန်တင်ကား၊ ရထားနဲ့ သင်္ဘောတွေက သေတ္တာကို ဘယ်လိုရွှေ့ရမလဲ သိဖို့ပဲ လိုပြီး အထဲမှာ ဘာပါလဲ သိစရာ မလိုတော့ပါဘူး။

Docker က software အတွက် အဲဒီအတိုင်း လုပ်ပေးပါတယ်။ Container ဆိုတာ app တစ်ခုအတွက် standard သေတ္တာပါ။ အထဲမှာ — code၊ runtime၊ library တွေနဲ့ setting တွေ။ အပြင်ဘက်မှာ — Docker run နေတဲ့ ဘယ်စက်မဆို start၊ stop၊ copy နဲ့ ချိတ်ဆက်တာ လုပ်တတ်တဲ့ standard ပုံသဏ္ဌာန်။ ကိုယ့် laptop၊ cloud server နဲ့ CI pipeline တို့ဟာ သေတ္တာ တစ်ခုတည်းကို သယ်တဲ့ “သင်္ဘော” အမျိုးမျိုးပဲ ဖြစ်ပါတယ်။

နည်းပညာအရ ပြောရရင် Docker ဆိုတာ ဒီသေတ္တာတွေကို build လုပ်ပေး (image)၊ run ပေး (container) ပြီး မျှဝေပေး (registry) တဲ့ platform တစ်ခုပါ။ Linux မှာ kernel feature တွေ (namespace နဲ့ cgroup) ကို သုံးပြီး container တစ်ခုချင်းစီကို file၊ process နဲ့ network တွေ သီးခြား မြင်ရအောင် လုပ်ပေးပြီး host ရဲ့ kernel ကိုတော့ မျှသုံးပါတယ်။ macOS နဲ့ Windows မှာတော့ Docker Desktop က နောက်ကွယ်မှာ Linux virtual machine အသေးလေး တစ်ခု run ပေးပါတယ်။

Virtual machine နဲ့ container ကွာခြားချက်

Virtual machine (VM) ကို အရင်က သုံးဖူးကောင်း သုံးဖူးပါလိမ့်မယ် — ဥပမာ Windows ပေါ်မှာ Ubuntu run နေတဲ့ VirtualBox။ VM နဲ့ container နှစ်ခုလုံးက app ကို သီးခြားခွဲထားပေးပေမဲ့ နည်းလမ်းချင်း အများကြီး ကွာပါတယ်။

ဥပမာ — VM ဆိုတာ သီးသန့်အိမ် တစ်လုံးပါ။ ကိုယ်ပိုင် အုတ်မြစ်၊ ရေပိုက်နဲ့ လျှပ်စစ် (ကိုယ်ပိုင် kernel ပါတဲ့ OS အပြည့်အစုံ) ရှိပါတယ်။ Container ကတော့ တိုက်ခန်း တစ်ခန်းပါ။ တိုက်ခန်းတိုင်းမှာ သော့ခတ်ထားတဲ့ တံခါးနဲ့ ပရိဘောဂ ကိုယ်စီ ရှိပေမဲ့ အဆောက်အအုံရဲ့ အုတ်မြစ်နဲ့ ရေပိုက် (host ရဲ့ kernel) ကို မျှသုံးကြပါတယ်။ တိုက်ခန်းတွေက အိမ်တွေထက် အများကြီး သက်သာပြီး ပြင်ဆင်ရတာလည်း မြန်ပါတယ်။

  • အထဲမှာ ဘာပါလဲ — VM: guest OS အပြည့်အစုံ နဲ့ app။ Container: app နဲ့ သူ့ library တွေပဲ။
  • အရွယ်အစား — VM: များသောအားဖြင့် GB အတော်များများ။ Container: မကြာခဏ 50–200 MB (node:22-alpine က 60 MB ခန့်)။
  • စတင်ချိန် — VM: စက္ကန့် ဆယ်ချီကနေ မိနစ်ချီ (OS ကို boot လုပ်ရလို့)။ Container: များသောအားဖြင့် တစ်စက္ကန့်အောက် (process တစ်ခု စတာပဲ မို့လို့)။
  • သီးခြားခွဲထားမှု — VM: အရမ်းခိုင်မာ၊ kernel သီးသန့်။ Container: ခိုင်မာပေမဲ့ kernel ကို မျှသုံး။
  • သိပ်သည်းဆ — VM: စက်တစ်လုံးမှာ အနည်းငယ်။ Container: စက်တစ်လုံးမှာ ဒါဇင်ချီ ဒါမှမဟုတ် ရာချီ။
  • ဘာအတွက် အကောင်းဆုံးလဲ — VM: OS တစ်မျိုး run ဖို့ ဒါမှမဟုတ် တင်းကြပ်တဲ့ security နယ်နိမိတ်။ Container: app တွေကို package လုပ်ပြီး ပို့ဖို့၊ microservice တွေ၊ CI။

အဓိက Concept များ

Docker မှာ နေ့တိုင်း သုံးရမယ့် စကားလုံး ခုနစ်လုံး ရှိပါတယ်။ ဒါတွေကို နားလည်သွားရင် Docker command တိုင်း အဓိပ္ပာယ် ရှိလာပါလိမ့်မယ်။

Dockerfile — ချက်ပြုတ်နည်း

ဥပမာ — ဟင်းချက်နည်း ကတ်ပြား။ ပါဝင်ပစ္စည်းတွေနဲ့ အဆင့်တွေကို အစဉ်လိုက် ရေးထားပါတယ် — “ဒီ base နဲ့ စ၊ ဒီ file တွေ ထည့်၊ ဒီ command ကို run။” Dockerfile ဆိုတာ image တစ်ခုကို ဘယ်လို build လုပ်ရမလဲ ဖော်ပြထားတဲ့ ညွှန်ကြားချက်တွေပါတဲ့ ရိုးရိုး text file တစ်ခုပါ။

dockerfile
FROM node:22-alpineWORKDIR /appCOPY . .CMD ["node", "index.js"]

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Alpine Linux အသေးလေးပေါ်မှာ Node.js 22 ပါပြီးသား official image တစ်ခုကနေ စပါ။
  2. ဒီ image ကနေ container တစ်ခု စတဲ့အခါ run မယ့် command။

လေးကြောင်းပဲ — Node.js 22 နဲ့ စ၊ /app ထဲမှာ အလုပ်လုပ်၊ project ကို copy ကူးထည့်ပြီး container စတဲ့အခါ index.js ကို run။ တကယ့် Dockerfile တွေကို နောက်ပိုင်းမှာ ရေးပါမယ်။

Image — အေးခဲထားတဲ့ အသင့်စား ဟင်း

ဥပမာ — အသင့်ချက်ပြီး ရေခဲသေတ္တာထဲ ထည့်ထားတဲ့ ဟင်းထုပ်။ ချက်ပြုတ်နည်း (Dockerfile) ကို တစ်ခါ ချက်ပြီး ရလဒ်ကို အေးခဲထားလိုက်တာပါ။ Image ဆိုတာ filesystem တစ်ခုလုံးနဲ့ setting တချို့ကို read-only အဖြစ် ဓာတ်ပုံရိုက်ထားတာပါ — OS file တွေ၊ Node.js၊ ကိုယ့် code၊ node_modules နဲ့ start command။ Image ကို ပြင်လို့ မရပါဘူး၊ အသစ်တစ်ခု build လုပ်ရပါတယ်။

bash
docker build -t my-app:1.0 .   # build an image from the Dockerfile in this folderdocker images                    # list the images on your machine

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. -t က image ကို နာမည်နဲ့ tag (name:tag) ပေးပါတယ်။ Tag က များသောအားဖြင့် version ပါ။ မထည့်ရင် Docker က latest ကို သုံးပါတယ်။ နောက်ဆုံးက . က build context — Docker က file တွေ copy ယူခွင့်ရှိတဲ့ folder ပါ။

docker build က ချက်ပြုတ်နည်းကို 1.0 tag ပါတဲ့ my-app ဆိုတဲ့ image အဖြစ် ချက်ပေးပါတယ်။ docker images က ကိုယ့်မှာရှိတဲ့ image အားလုံးကို အရွယ်အစားနဲ့တကွ ပြပါတယ်။

Container — ပန်းကန်ထဲ ထည့်ထားတဲ့ ဟင်း

ဥပမာ — အေးခဲထားတဲ့ ထုပ်ကနေ နွှေးပြီး ပန်းကန်ထဲ ထည့်ထားတဲ့ ဟင်း။ အေးခဲဟင်း တစ်မျိုးတည်းကို ပန်းကန် အများကြီးမှာ ခူးလို့ရပါတယ်။ Container ဆိုတာ image တစ်ခုကနေ run နေတဲ့ instance ပါ — read-only image ရဲ့ အပေါ်မှာ ကိုယ်ပိုင် ရေးလို့ရတဲ့ အလွှာ ပါတဲ့ တကယ့် process တစ်ခုပါ။ Image တစ်ခုကနေ container အများကြီး စလို့ရပါတယ်။

bash
docker run -d --name app1 -p 3000:3000 my-app:1.0   # start a container in the backgrounddocker ps                                           # list running containers

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. -d က နောက်ကွယ်မှာ (detached) run ပါတယ်။ --name က ခေါ်ရလွယ်တဲ့ နာမည်ပေးပါတယ်။ -p 3000:3000 က ကိုယ့်ကွန်ပျူတာရဲ့ port 3000 ကို container ထဲက port 3000 နဲ့ ချိတ်ပေးပါတယ်။

Container ကို ဖျက်လိုက်ရင် အထဲမှာ ရေးထားသမျှ ပျောက်သွားပေမဲ့ image ကတော့ ဘာမှ မဖြစ်ပါဘူး။ ဒါကြောင့် database အတွက် volume လိုမှာပါ။

Registry — စူပါမားကတ်

ဥပမာ — ဘယ်သူမဆို ဝယ်ယူသွားလို့ရတဲ့ အေးခဲဟင်းတွေ အပြည့်ရှိတဲ့ စူပါမားကတ်ရဲ့ ရေခဲသေတ္တာ။ Registry က image တွေကို သိမ်းပြီး မျှဝေပေးပါတယ်။ Docker Hub က default public registry ဖြစ်ပြီး official node၊ mongo နဲ့ nginx image တွေ အဲဒီမှာ ရှိပါတယ်။ ကုမ္ပဏီတွေက GitHub Container Registry၊ AWS ECR ဒါမှမဟုတ် Google Artifact Registry လို private registry တွေကိုလည်း သုံးကြပါတယ်။

bash
docker pull mongo:7                 # download an image from Docker Hubdocker push yourname/my-app:1.0     # upload your own image (after docker login)

pull က registry ကနေ image ကို ယူပါတယ်၊ push က ကိုယ့် image ကို အဲဒီကို တင်ပေးပြီး နောက်ပိုင်း server တစ်ခုက pull လုပ်လို့ရအောင် လုပ်ပေးပါတယ်။

Volume — External hard drive

ဥပမာ — External hard drive တစ်လုံး။ Laptop တစ်လုံးကနေ ဖြုတ်ပြီး laptop အသစ်မှာ တပ်လိုက်ရင် file တွေ ရှိနေဆဲပါ။ Volume ဆိုတာ ဘယ် container ရဲ့ အပြင်ဘက်မှာမဆို နေတဲ့ Docker က စီမံတဲ့ storage ပါ။ Container ကို ဖျက်ပြီး volume တူတူနဲ့ အသစ်တစ်ခု စလိုက်ရင် data တွေ ရှိနေဆဲပါ။ Database တွေမှာ အမြဲလိုပါတယ်။

bash
docker volume create mongo-datadocker run -d --name db -v mongo-data:/data/db mongo:7

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. -v VOLUME:PATH က volume ကို MongoDB က သူ့ file တွေ သိမ်းတဲ့ /data/db မှာ တပ်ပေးပါတယ်။ အခု data က container ထက် ပိုကြာကြာ အသက်ရှင်ပါတယ်။

Mount နောက်တစ်မျိုး ရှိပါသေးတယ် — bind mount (-v ./src:/app/src) ပါ။ ကိုယ့်ကွန်ပျူတာက folder တစ်ခုကို container နဲ့ မျှသုံးပေးပါတယ်။ Development မှာ hot reload အတွက် သုံးပါမယ်။

Network — ရုံးတွင်း ဖုန်းစနစ်

ဥပမာ — ရုံးတစ်ခုရဲ့ အတွင်းပိုင်း ဖုန်းစနစ်။ လုပ်ဖော်ကိုင်ဖက်တွေက အပြင်ဖုန်းနံပါတ် အပြည့် နှိပ်စရာမလိုဘဲ နာမည်နဲ့ (“Mongo ကို ခေါ်ပေး”) ခေါ်ကြပါတယ်။ Docker network တစ်ခုတည်းပေါ်က container တွေက တစ်ခုနဲ့တစ်ခု နာမည် နဲ့ ဆက်သွယ်လို့ရပြီး Docker ရဲ့ built-in DNS က အဲဒီနာမည်ကို မှန်ကန်တဲ့ IP address အဖြစ် ပြောင်းပေးပါတယ်။

bash
docker network create app-netdocker run -d --name mongo --network app-net mongo:7docker run -d --name api --network app-net -e MONGO_URI=mongodb://mongo:27017/todos my-app:1.0

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. API က database ကို တခြား container ရဲ့ နာမည်ဖြစ်တဲ့ mongo hostname နဲ့ ဆက်သွယ်ပါတယ်။ localhost မဟုတ်ပါဘူး။

Container နှစ်ခုလုံး app-net ထဲ ဝင်ထားလို့ API က mongo:27017 ကို ချိတ်လို့ရပါတယ်။ Network ပြင်ပက container တွေကတော့ လုံးဝ ဆက်သွယ်လို့ မရပါဘူး။

Docker Compose — သံစုံတီးဝိုင်းရဲ့ conductor

ဥပမာ — သံစုံတီးဝိုင်းရဲ့ conductor။ ဂီတသမား (container) တစ်ယောက်ချင်းစီ တစ်ယောက်တည်း တီးလို့ရပေမဲ့ conductor က သူတို့ကို မှန်ကန်တဲ့ အစဉ်နဲ့ စတင်ပေးပြီး တစ်သားတည်း ဖြစ်အောင် ထိန်းပေးပါတယ်။ Docker Compose က app တစ်ခုလုံး (container၊ network နဲ့ volume အားလုံး) ကို YAML file တစ်ခုထဲမှာ ဖော်ပြပြီး command တစ်ကြောင်းတည်းနဲ့ အားလုံးကို စတင်ပေးပါတယ်။

yaml
services:  mongo:    image: mongo:7    volumes:      - mongo-data:/data/db  api:    build: .    environment:      MONGO_URI: mongodb://mongo:27017/todos    ports:      - "3000:3000"volumes:  mongo-data:

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. services အောက်က entry တစ်ခုချင်းစီက container တစ်ခု (သို့) တစ်ခုထက်ပို ဖြစ်လာပါတယ်။ သူ့ key (mongo၊ api) က Compose ဖန်တီးတဲ့ network ပေါ်မှာ သူ့ hostname လည်း ဖြစ်ပါတယ်။
  2. image က အသင့်ရှိပြီးသား image ကို သုံးပါတယ်၊ build က အဲဒီ folder ထဲက Dockerfile ကနေ image တစ်ခု build လုပ်ပါတယ်။

အပေါ်က docker run command သုံးကြောင်းက ဖတ်ရလွယ်တဲ့ file တစ်ခု ဖြစ်သွားပါတယ်။ docker compose up က အားလုံးကို စပေးပြီး docker compose down က အားလုံးကို ရပ်ပေးပါတယ်။

Docker install လုပ်ပြီး ပထမဆုံး container တွေ run ကြည့်မယ်

Install လုပ်ခြင်း

  • macOS — docker.com ကနေ Docker Desktop ကို install လုပ်ပါ (Apple Silicon ဒါမှမဟုတ် Intel ကို ရွေးပါ)၊ တစ်ခါဖွင့်ပြီး menu bar က ဝေလငါး icon လှုပ်ရှားနေတာ ရပ်တဲ့အထိ စောင့်ပါ။
  • Windows 10/11 — Docker Desktop ကို install လုပ်ပါ။ WSL 2 ကို သုံးတာမို့ ဖွင့်ခိုင်းတဲ့အခါ လက်ခံပါ။ File access ပိုမြန်အောင် project တွေကို WSL filesystem ထဲမှာ (ဥပမာ Ubuntu ထဲက ~/projects) ထားပါ။
  • Linux (Ubuntu၊ Debian စသည်) — Docker ရဲ့ convenience script နဲ့ Docker Engine ကို install လုပ်ပြီး ကိုယ့် user က sudo မပါဘဲ Docker ကို run ခွင့်ရအောင် လုပ်ပါ။
bash
# Linux only: install Docker Engine + the Compose plugincurl -fsSL https://get.docker.com | shsudo usermod -aG docker $USER   # then log out and log back in # Everyone: check that it worksdocker --versiondocker compose version

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. ကိုယ့် user ကို docker group ထဲ ထည့်ပေးလို့ sudo မပါဘဲ docker ကို run လို့ရပါတယ်။ ပြန် login ဝင်ပြီးမှ အသက်ဝင်ပါတယ်။
  2. Compose v2 က plugin တစ်ခုပါ — command က docker compose (space နဲ့) ပါ။ အဟောင်း docker-compose (dash နဲ့) ကို အသုံးမပြုတော့ပါဘူး။

Version command နှစ်ခုလုံး version နံပါတ် ထုတ်ပြရင် အဆင်သင့် ဖြစ်ပါပြီ။

Hello, world

bash
docker run hello-world

ဒီ command တစ်ကြောင်းက အလုပ်အများကြီး လုပ်ပါတယ်။ Docker က hello-world image ကို ကိုယ့်စက်ထဲမှာ ရှာ၊ မတွေ့လို့ Docker Hub ကနေ pull လုပ်၊ အဲဒီကနေ container တစ်ခု create လုပ်၊ run (ကြိုဆိုစာ ထုတ်ပြ) ပြီး program ပြီးသွားတာနဲ့ container က exit ဖြစ်သွားပါတယ်။ Container တစ်ခုဟာ သူ့ main process အသက်ရှင်သလောက်ပဲ အသက်ရှင်ပါတယ်။

တကယ့် web server — nginx

bash
docker run -d --name web -p 8080:80 nginx:alpine   # start nginx in the backgrounddocker ps                                          # is it running?curl http://localhost:8080                         # or open it in your browserdocker logs web                                    # what has it printed?docker exec -it web sh                             # open a shell INSIDE the container (type exit to leave)docker stop web                                    # stop itdocker rm web                                      # delete the container (the image stays)

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Port mapping က အမြဲ HOST:CONTAINER ပါ။ nginx က container ထဲမှာ port 80 ကို နားထောင်ပြီး ကိုယ်က ကိုယ့်ကွန်ပျူတာရဲ့ port 8080 ကနေ ဝင်ပါတယ်။
  2. exec က run နေတဲ့ container ထဲမှာ command တစ်ခု run ပါတယ်။ -it က terminal နဲ့ interactive ဖြစ်အောင် လုပ်ပါတယ်။ Alpine image တွေမှာ bash မပါဘဲ sh ပဲ ပါပါတယ်။
  3. ရပ်ထားတဲ့ container တွေက မဖျက်မချင်း ရှိနေဆဲပါ။ docker ps -a က အားလုံးကို ပြပါတယ်။ docker run --rm ဆိုရင် container ရပ်တာနဲ့ အလိုအလျောက် ဖျက်ပေးပါတယ်။

http://localhost:8080 ကို ဖွင့်ကြည့်ရင် “Welcome to nginx!” ကို မြင်ရပါလိမ့်မယ်။ Install မလုပ်ဘဲ production web server တစ်ခု run လိုက်တာပါ။ Container ကို ဖျက်လိုက်ရင် ကိုယ့်ကွန်ပျူတာက အရင်အတိုင်း သန့်ရှင်းနေဆဲပါ။

Dockerfile ညွှန်ကြားချက်များနဲ့ layer caching

Dockerfile တိုင်းနီးပါးမှာ သုံးရမယ့် ညွှန်ကြားချက်တွေက ဒါတွေပါ—

  • FROM image:tag — စတင်မယ့် base image။ Dockerfile တိုင်း ဒီနဲ့ စပါတယ်။
  • WORKDIR /app — နောက်က ညွှန်ကြားချက်တွေအတွက် အလုပ်လုပ်မယ့် folder (မရှိရင် ဖန်တီးပေး)။ cd လိုပဲ၊ ဒါပေမဲ့ အမြဲတမ်း။
  • COPY src dest — build context ထဲက file တွေကို image ထဲ copy ကူးပါတယ်။ (ADD က archive ဖြေတာနဲ့ URL ကနေ download လုပ်တာပါ လုပ်နိုင်ပါတယ်၊ အဲဒါ မလိုရင် COPY ကို ပိုသုံးပါ။)
  • RUN command — build လုပ်ချိန်မှာ command တစ်ခု run ပါတယ်၊ ဥပမာ npm ci။ ရလဒ်ကို image ထဲမှာ သိမ်းပါတယ်။
  • ENV KEY=value — build အတွက်ရော run နေတဲ့ container အတွက်ပါ environment variable သတ်မှတ်ပါတယ်။
  • ARG NAME=default — build လုပ်ချိန်မှာပဲ ရှိတဲ့ variable (docker build --build-arg NAME=x)။
  • EXPOSE 4000 — app က ဘယ် port ကို နားထောင်လဲ မှတ်တမ်းတင်ပါတယ်။ Port ကို publish မလုပ်ပါဘူး — အဲဒါကို -p ဒါမှမဟုတ် Compose ports က လုပ်ပါတယ်။
  • USER node — နောက်က အဆင့်တွေနဲ့ container ကို root အစား ဒီ user အဖြစ် run ပါတယ်။
  • CMD ["node", "server.js"] — container စတဲ့အခါ default command။ နောက်ဆုံး CMD တစ်ခုပဲ အကျုံးဝင်ပြီး docker run မှာ override လုပ်လို့ရပါတယ်။
  • ENTRYPOINT [...] — ပုံသေ command တစ်ခု၊ CMD က သူ့ default argument တွေကို ပေးပါတယ်။ Tool တစ်ခုလို အလုပ်လုပ်တဲ့ image တွေအတွက် အသုံးဝင်ပါတယ်။
  • HEALTHCHECK CMD ... — “ဒီ container ကျန်းမာရဲ့လား?” လို့ Docker က အချိန်အပိုင်းအခြားနဲ့ မေးကြည့်တဲ့ command။

Layer caching ဘယ်လို အလုပ်လုပ်လဲ

ဥပမာ — အကြည်စာရွက်တွေ အထပ်လိုက်။ Dockerfile ထဲက ညွှန်ကြားချက် တစ်ခုချင်းစီက ယခင်စာရွက်တွေပေါ်မှာ စာရွက် (layer) တစ်ရွက် ထပ်တင်ပါတယ်။ ပြန် build လုပ်တဲ့အခါ Docker က အပေါ်ကနေ အဆင့်တိုင်းကို စစ်ပါတယ် — ညွှန်ကြားချက်နဲ့ သူ့ input file တွေ မပြောင်းရင် သိမ်းထားတဲ့ layer ကို ပြန်သုံးပါတယ် (CACHED)။ အဆင့်တစ်ခု ပြောင်းတာနဲ့ အဲဒီအဆင့်နဲ့ သူ့နောက်က အဆင့်တိုင်း ကို ပြန် build လုပ်ပါတယ်။

ဒါကြောင့် ညွှန်ကြားချက်တွေရဲ့ အစဉ် က အရမ်းအရေးကြီးပါတယ်။ App တစ်ခုတည်းအတွက် Dockerfile နှစ်ခုကို နှိုင်းယှဉ်ကြည့်ပါ—

dockerfile
# ❌ Slow: any code change re-runs npm installFROM node:22-alpineWORKDIR /appCOPY . .RUN npm ciCMD ["node", "src/server.js"]

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Source code အပါအဝင် အားလုံးကို copy ကူးပါတယ်။ Code တစ်ကြောင်း ပြင်လိုက်တာနဲ့ ဒီ layer ပြောင်းသွားလို့ အောက်က npm ci ပြန် run ရပါတယ် — ပြင်ဆင်မှု အသေးလေး တိုင်းအတွက် တစ်မိနစ် စောင့်ရပါတယ်။
dockerfile
# ✅ Fast: npm install runs only when the dependencies changeFROM node:22-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .CMD ["node", "src/server.js"]

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Dependency စာရင်းကိုပဲ အရင် copy ကူးပါ။ ဒီ file တွေက ခဏခဏ မပြောင်းပါဘူး။
  2. ဒီ ကုန်ကျစရိတ်များတဲ့ layer ကို package.json ဒါမှမဟုတ် lock file မပြောင်းမချင်း cache ကနေ ပြန်သုံးပါတယ်။
  3. Code ပြောင်းရင် ဒီကနေ အောက်ကိုပဲ ပြန် build လုပ်ပါတယ် — မိနစ်ချီအစား စက္ကန့်ပိုင်းပဲ ကြာပါတယ်။

မှတ်ထားရမယ့် စည်းမျဉ်း — အပြောင်းအလဲ အနည်းဆုံး အရာတွေကို အပေါ်မှာ၊ အပြောင်းအလဲ အများဆုံး အရာတွေကို အောက်မှာ ထားပါ။ Base image၊ ပြီးရင် system package တွေ၊ ပြီးရင် dependency တွေ၊ ပြီးမှ ကိုယ့် code။

Project — MERN Todo app

တကယ့်အရာတစ်ခု တည်ဆောက်ချိန် ရောက်ပါပြီ — task တွေ ထည့်၊ ပြီးကြောင်း မှတ်၊ ဖျက်လို့ရတဲ့ Todo app တစ်ခုပါ။ အပိုင်း သုံးပိုင်း ရှိပြီး တစ်ပိုင်းချင်းစီ ကိုယ်ပိုင် container ထဲမှာ run ပါတယ်—

  • mongo — MongoDB 7 database၊ သူ့ file တွေကို named volume ထဲမှာ သိမ်းပါတယ်။
  • backend — port 4000 ပေါ်က Express 5 + Mongoose API။
  • frontend — Vite နဲ့ build လုပ်ပြီး port 80 ပေါ်မှာ nginx က serve လုပ်တဲ့ React app၊ nginx က /api request တွေကို backend ဆီ လွှဲပေးပါသေးတယ်။

Folder ဖွဲ့စည်းပုံ

text
mern-todo/├── docker-compose.yml        # the whole stack (production-like)├── docker-compose.dev.yml    # development overrides (hot reload)├── .env.example              # sample settings: copy it to .env├── backend/│   ├── Dockerfile│   ├── .dockerignore│   ├── package.json│   └── src/│       ├── server.js         # Express app + MongoDB connection│       ├── models/│       │   └── Todo.js       # Mongoose schema│       └── routes/│           └── todos.js      # CRUD routes└── frontend/    ├── Dockerfile            # multi-stage: build with Node, serve with nginx    ├── .dockerignore    ├── nginx.conf    ├── package.json    ├── vite.config.js    ├── index.html    └── src/        ├── main.jsx        ├── App.jsx        └── index.css

ဒီ folder တွေကို အခုပဲ ဖန်တီးထားပါ။ နောက်အပိုင်းတွေမှာ file တိုင်းကို အစဉ်လိုက် ဖြည့်ပါမယ်။

Architecture

text
Your browser     │  http://localhost:8080     │  (port 8080 on your computer → port 80 in the container)     ▼┌──────────────────── Docker network: app-net ────────────────────┐│                                                                 ││  ┌──────────────────┐    /api/*     ┌───────────────┐           ││  │ frontend         │ ────────────► │ backend       │           ││  │ nginx :80        │  backend:4000 │ Express :4000 │           ││  │ React build      │               │ Mongoose      │           ││  └──────────────────┘               └───────┬───────┘           ││                                             │ mongo:27017       ││                                             ▼                   ││                                     ┌───────────────┐           ││                                     │ mongo         │           ││                                     │ MongoDB 7     │           ││                                     └───────┬───────┘           │└─────────────────────────────────────────────┼───────────────────┘                                              ▼                                  volume: mongo-data (/data/db)

Frontend တစ်ခုပဲ ကိုယ့်ကွန်ပျူတာဆီ publish လုပ်ထားပါတယ် (port 8080)။ Browser က nginx ဆီကနေ React app ကို load လုပ်ပြီး လိပ်စာ တစ်ခုတည်း ပေါ်မှာ /api/todos ကို ခေါ်ပါတယ်။ nginx က အဲဒီ call တွေကို private network ကနေ backend:4000 ဆီ လွှဲပေးပြီး backend က mongo:27017 နဲ့ ဆက်သွယ်ပါတယ်။ Database နဲ့ API က ပြင်ပကမ္ဘာကို ဘယ်တော့မှ မပေါ်ပါဘူး။ Browser က origin တစ်ခုတည်းနဲ့ပဲ ဆက်သွယ်တာမို့ CORS တောင် မလိုပါဘူး။

Backend — Express + Mongoose

package.json

backend/package.json ကို ဖန်တီးပါ—

json
{  "name": "mern-todo-backend",  "version": "1.0.0",  "private": true,  "type": "module",  "scripts": {    "start": "node src/server.js",    "dev": "node --watch src/server.js"  },  "dependencies": {    "express": "^5.1.0",    "mongoose": "^9.2.0"  }}

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Node.js မှာ import / export syntax ကို သုံးလို့ရအောင် လုပ်ပေးပါတယ်။
  2. node --watch က file တစ်ခု ပြောင်းတိုင်း server ကို restart လုပ်ပေးပါတယ်။ Node.js ထဲမှာ ပါပြီးသားမို့ nodemon မလိုပါဘူး။

Dependency နှစ်ခုပဲ — HTTP server အတွက် Express နဲ့ MongoDB အတွက် Mongoose။ အခု backend/ ထဲမှာ npm install ကို တစ်ခါ run ပါ။ Dockerfile လိုအပ်တဲ့ package-lock.json ကို ဖန်တီးပေးပါတယ် (အောက်မှာ ဆက်ရှင်းပါမယ်)။

Todo model

backend/src/models/Todo.js ကို ဖန်တီးပါ—

js
import mongoose from 'mongoose'; // The shape of one todo document in MongoDB.const todoSchema = new mongoose.Schema(  {    title: {      type: String,      required: [true, 'Title is required'], // Mongoose rejects a todo without a title      trim: true,      maxlength: 200,    },    completed: { type: Boolean, default: false },  },  { timestamps: true }, // adds createdAt and updatedAt automatically); // Collection name becomes "todos" (lowercased and pluralised).export default mongoose.model('Todo', todoSchema);

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Title မပါတဲ့ todo ကို save လုပ်ရင် ValidationError နဲ့ fail ဖြစ်ပြီး ကျွန်တော်တို့ရဲ့ error handler က 400 response အဖြစ် ပြောင်းပေးပါတယ်။
  2. Mongoose က createdAt နဲ့ updatedAt ကို ဖြည့်ပေးပါတယ်၊ ကျွန်တော်တို့က createdAt နဲ့ စီပါတယ်။

Schema က todo တစ်ခု ဘယ်လိုပုံစံလဲ ဖော်ပြပါတယ် — စာလုံး 200 အထိ ရှိရမယ့် title နဲ့ false နဲ့ စတဲ့ completed flag။

Route များ

backend/src/routes/todos.js ကို ဖန်တီးပါ—

js
import { Router } from 'express';import Todo from '../models/Todo.js'; const router = Router(); // GET /api/todos: every todo, newest firstrouter.get('/', async (req, res) => {  const todos = await Todo.find().sort({ createdAt: -1 });  res.json(todos);}); // POST /api/todos: create one from { "title": "..." }router.post('/', async (req, res) => {  const todo = await Todo.create({ title: req.body?.title });  res.status(201).json(todo); // 201 = Created}); // PATCH /api/todos/:id: change the title and/or completedrouter.patch('/:id', async (req, res) => {  const { title, completed } = req.body ?? {};  const todo = await Todo.findByIdAndUpdate(    req.params.id,    { title, completed }, // undefined fields are left unchanged    { returnDocument: 'after', runValidators: true }, // return the updated todo, and validate it  );  if (!todo) return res.status(404).json({ error: 'Todo not found' });  res.json(todo);}); // DELETE /api/todos/:idrouter.delete('/:id', async (req, res) => {  const todo = await Todo.findByIdAndDelete(req.params.id);  if (!todo) return res.status(404).json({ error: 'Todo not found' });  res.status(204).end(); // 204 = done, nothing to send back}); export default router;

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Express 5 မှာ request တစ်ခုမှာ JSON body မပါရင် req.body က undefined ဖြစ်ပါတယ်။ ?. က crash မဖြစ်အောင် ကာကွယ်ပြီး Mongoose က title မပါကြောင်း report လုပ်ပါတယ်။
  2. Update ပြီးနောက် todo ကို ပြန်ပေးပြီး schema ရဲ့ validator တွေကိုလည်း run ပါတယ်။ (new: true ကလည်း အတူတူ လုပ်ပေမဲ့ Mongoose 9 မှာ deprecated ဖြစ်နေပါပြီ။)
  3. 204 ဆိုတာ “ပြီးပြီ၊ ပြန်ပို့စရာ ဘာမှမရှိ” ဆိုတဲ့ အဓိပ္ပာယ်ဖြစ်ပြီး delete အောင်မြင်ရင် ပုံမှန်ပြန်ပေးတဲ့ အဖြေပါ။

Route လေးခုက CRUD အပြည့်အစုံ ပေးပါတယ် — list၊ create၊ update (ပြီးကြောင်းမှတ် / နာမည်ပြောင်း) နဲ့ delete။ id မှားရင် 404 ပြန်ပေးလို့ frontend က ရှင်းလင်းတဲ့ message ပြနိုင်ပါတယ်။

Server

backend/src/server.js ကို ဖန်တီးပါ—

js
import express from 'express';import mongoose from 'mongoose';import todosRouter from './routes/todos.js'; // Settings come from environment variables, so the same image runs anywhere.const PORT = process.env.PORT || 4000;const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/todos'; const app = express();app.use(express.json()); // parse JSON request bodies into req.body // Health check: Docker asks this URL "are you OK?"app.get('/api/health', (req, res) => {  const dbReady = mongoose.connection.readyState === 1; // 1 = connected  res.status(dbReady ? 200 : 503).json({ status: dbReady ? 'ok' : 'database not ready' });}); app.use('/api/todos', todosRouter); // One place for every error. Express 5 sends errors from async routes here too.app.use((err, req, res, next) => {  const badInput = err.name === 'ValidationError' || err.name === 'CastError';  if (!badInput) console.error(err);  res.status(badInput ? 400 : 500).json({ error: badInput ? err.message : 'Something went wrong' });}); async function start() {  await mongoose.connect(MONGO_URI);  console.log('Connected to MongoDB');   const server = app.listen(PORT, () => console.log(`API listening on port ${PORT}`));   // `docker stop` sends SIGTERM: finish open requests, close the DB, then exit.  const shutdown = (signal) => {    console.log(`${signal} received, shutting down`);    server.close(async () => {      await mongoose.disconnect();      process.exit(0);    });  };  process.on('SIGTERM', shutdown);  process.on('SIGINT', shutdown);} start().catch((err) => {  console.error('Failed to start:', err.message);  process.exit(1); // a non-zero exit lets Docker's restart policy try again});

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Connection string က environment variable ကနေ လာပါတယ်။ Image တစ်ခုတည်းက code မပြင်ဘဲ local MongoDB၊ container ဒါမှမဟုတ် MongoDB Atlas ကို ချိတ်နိုင်ပါတယ်။ Default value က Docker မပါဘဲ run တဲ့အခါအတွက်ပါ။
  2. Health endpoint တစ်ခုပါ။ Process အသက်ရှင်နေရုံ မဟုတ်ဘဲ API က တကယ် အလုပ်လုပ်နေလား (database နဲ့ ချိတ်မိနေလား) ဆိုတာ စစ်ဖို့ Docker က ခေါ်ပါတယ်။
  3. Express 5 က async route တွေထဲမှာ throw လုပ်တဲ့ error တွေကို ဒီ handler ဆီ အလိုအလျောက် ပို့ပေးပါတယ်။ Input မှားရင် 400၊ ကျန်တာတွေက အသေးစိတ် မပေါက်ကြားဘဲ 500 ဖြစ်ပါတယ်။
  4. docker stop က SIGTERM ပို့ပါတယ်။ 10 စက္ကန့်အကြာ request တစ်ဝက်တစ်ပျက်မှာ kill ခံရမယ့်အစား request အသစ် လက်ခံတာ ရပ်၊ database connection ပိတ်ပြီး သေသေသပ်သပ် ထွက်ပါတယ်။
  5. Startup မှာ MongoDB ကို ဆက်သွယ်လို့ မရရင် process က error code နဲ့ ထွက်ပြီး Compose ထဲက restart: unless-stopped policy က ထပ်ကြိုးစားပေးပါတယ်။

Server က MongoDB ကို အရင်ချိတ်ပြီးမှ နားထောင်ပါတယ်၊ ဒါကြောင့် ဖြေမပေးနိုင်တဲ့ request တွေကို ဘယ်တော့မှ လက်မခံပါဘူး။ Environment အလိုက် ကွာခြားတဲ့ အရာအားလုံး (port၊ database လိပ်စာ) က environment variable ကနေ လာပါတယ်။ ဒါက ဘယ်နေရာမှာမဆို run တဲ့ image တစ်ခုရဲ့ သော့ချက်ပါ။

Backend Dockerfile

backend/Dockerfile ကို ဖန်တီးပါ—

dockerfile
# 1. Start from a small official image that already has Node.js 22FROM node:22-alpine # 2. Run in production mode (some libraries skip dev-only work)ENV NODE_ENV=production # 3. Every command below runs inside /app in the imageWORKDIR /app # 4. Copy ONLY the package files first, so this layer stays cached#    until the dependencies changeCOPY package.json package-lock.json ./ # 5. Install exactly what the lock file says, without devDependenciesRUN npm ci --omit=dev # 6. Now copy the source code (changes often, so it comes last)COPY src ./src # 7. Don't run as root: the node image ships a user called "node"USER node # 8. Documents the port the app listens on (it does not publish it)EXPOSE 4000 # 9. The command that runs when a container startsCMD ["node", "src/server.js"]

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. alpine image တွေက အရမ်းသေးပါတယ် (Node.js အတွက် 60 MB ခန့်)၊ ဒါကြောင့် download မြန်ပြီး security ပေါက်ကြားနိုင်တဲ့ package နည်းပါတယ်။
  2. အစောပိုင်းက layer caching နည်းလမ်း — dependency အရင်၊ code နောက်မှ။
  3. npm ci က lock file ထဲမှာ ပါသလောက် အတိအကျ install လုပ်လို့ build တိုင်း တူညီပါတယ်။ package-lock.json မရှိရင် fail ဖြစ်ပါတယ် — ဒါကြောင့် npm install ကို တစ်ခါ run ခဲ့တာပါ။ --omit=dev က devDependencies တွေကို ချန်ထားပါတယ်။
  4. Container ထဲမှာ root အဖြစ် run တာ မလိုအပ်တဲ့ အန္တရာယ်ပါ။ Official Node.js image တွေမှာ ဒီအတွက် node user ပါပြီးသားပါ။
  5. Exec form မို့ Node.js က SIGTERM ကို တိုက်ရိုက်ရပြီး ကျွန်တော်တို့ရဲ့ graceful shutdown run ပါတယ်။

ဒါက API အတွက် production image ပါ — Node.js 22၊ production dependency တွေပဲ၊ ကိုယ့် source code၊ ပုံမှန် user အဖြစ် run ပါတယ်။

.dockerignore

backend/.dockerignore ကို ဖန်တီးပါ—

dotenv
node_modulesnpm-debug.log.env.gitDockerfile.dockerignore

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. အရေးအကြီးဆုံး စာကြောင်းပါ။ ကိုယ့် local node_modules ထဲမှာ macOS ဒါမှမဟုတ် Windows အတွက် build လုပ်ထားတဲ့ package တွေ ပါနိုင်ပြီး Linux image ထဲကို ဘယ်တော့မှ copy မကူးရပါဘူး။ Image က ကိုယ်ပိုင် install လုပ်ပါတယ်။
  2. Secret တွေကို image ထဲ ဘယ်တော့မှ မထည့်ပါနဲ့။ Image ကို pull လုပ်တဲ့ ဘယ်သူမဆို ဖတ်လို့ရသွားပါလိမ့်မယ်။

.dockerignore က .gitignore လိုပဲ အလုပ်လုပ်ပေမဲ့ build context အတွက်ပါ။ Build ကို မြန်စေပြီး (Docker ဆီ ပို့ရတာ နည်းလို့) image ကို သန့်ရှင်းစေပါတယ်။

Frontend — React + Vite၊ nginx နဲ့ serve လုပ်ခြင်း

package.json နဲ့ Vite config

frontend/package.json ကို ဖန်တီးပါ—

json
{  "name": "mern-todo-frontend",  "version": "1.0.0",  "private": true,  "type": "module",  "scripts": {    "dev": "vite",    "build": "vite build",    "preview": "vite preview"  },  "dependencies": {    "react": "^19.2.0",    "react-dom": "^19.2.0"  },  "devDependencies": {    "@vitejs/plugin-react": "^6.0.0",    "vite": "^8.0.0"  }}

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. vite build က React code ကို dist/ ထဲမှာ ရိုးရိုး static file (HTML၊ CSS၊ JS) တွေအဖြစ် ပြောင်းပေးပါတယ်။ nginx လိုအပ်တာ ဒါတွေပဲ။

ပြီးရင် frontend/vite.config.js ကို ဖန်တီးပါ—

js
import { defineConfig } from 'vite';import react from '@vitejs/plugin-react'; export default defineConfig({  plugins: [react()],  server: {    host: true, // listen on 0.0.0.0, so the dev server is reachable from outside its container    port: 5173,    // In development, Vite forwards /api to the backend.    // On your machine that is localhost:4000; inside Compose it is http://backend:4000.    proxy: {      '/api': process.env.API_URL || 'http://localhost:4000',    },    // Some setups (Windows files mounted into a container) miss file-change events: poll instead.    watch: { usePolling: process.env.WATCH_POLLING === 'true' },  },});

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Default အားဖြင့် Vite က ကိုယ့် container ထဲမှာပဲ localhost ကို နားထောင်လို့ browser က ဆက်သွယ်လို့ မရပါဘူး။ host: true က network interface တိုင်းပေါ်မှာ နားထောင်စေပါတယ်။
  2. Development မှာ Vite server က /api call တွေကို backend ဆီ လွှဲပေးပါတယ်။ ကိုယ့်စက်ပေါ်မှာ ဆိုရင် localhost:4000၊ Compose ထဲမှာတော့ API_URL=http://backend:4000 — service name ကို သတ်မှတ်ပါတယ်။
  3. Container ထဲကို file ပြောင်းလဲမှု event မပို့ပေးတဲ့ file system တွေအတွက် (အဓိကအားဖြင့် WSL ပြင်ပက Windows folder တွေ) အရေးပေါ် ထွက်ပေါက်ပါ။

Backend လိုပဲ အကြောင်းရင်းနဲ့ frontend/ ထဲမှာ npm install ကို တစ်ခါ run ပါ — package-lock.json ကို ဖန်တီးပေးပါတယ်။

React code

frontend/index.html ကို ဖန်တီးပါ—

html
<!doctype html><html lang="en">  <head>    <meta charset="UTF-8" />    <meta name="viewport" content="width=device-width, initial-scale=1.0" />    <title>MERN Todo</title>  </head>  <body>    <div id="root"></div>    <script type="module" src="/src/main.jsx"></script>  </body></html>

frontend/src/main.jsx ကို ဖန်တီးပါ—

jsx
import { StrictMode } from 'react';import { createRoot } from 'react-dom/client';import App from './App.jsx';import './index.css'; createRoot(document.getElementById('root')).render(  <StrictMode>    <App />  </StrictMode>,);

ဒီ file နှစ်ခုက Vite ရဲ့ standard boilerplate ပါ — root div ပါတဲ့ HTML page နဲ့ ကျွန်တော်တို့ရဲ့ App component ကို အဲဒီထဲ mount လုပ်ပေးတဲ့ script။

frontend/src/App.jsx ကို ဖန်တီးပါ—

jsx
import { useEffect, useState } from 'react'; // A relative URL: the browser sends it to whoever served the page// (Vite in development, nginx in production), and they forward it to the backend.const API = '/api/todos'; async function request(url, options = {}) {  const res = await fetch(url, {    headers: { 'Content-Type': 'application/json' },    ...options,  });  if (!res.ok) {    const body = await res.json().catch(() => ({}));    throw new Error(body.error || `Request failed (${res.status})`);  }  return res.status === 204 ? null : res.json();} export default function App() {  const [todos, setTodos] = useState([]);  const [title, setTitle] = useState('');  const [loading, setLoading] = useState(true);  const [error, setError] = useState('');   // Load the list once, when the page opens  useEffect(() => {    request(API)      .then(setTodos)      .catch((err) => setError(err.message))      .finally(() => setLoading(false));  }, []);   async function addTodo(event) {    event.preventDefault();    if (!title.trim()) return;    try {      const todo = await request(API, { method: 'POST', body: JSON.stringify({ title }) });      setTodos((list) => [todo, ...list]);      setTitle('');      setError('');    } catch (err) {      setError(err.message);    }  }   async function toggleTodo(todo) {    try {      const updated = await request(`${API}/${todo._id}`, {        method: 'PATCH',        body: JSON.stringify({ completed: !todo.completed }),      });      setTodos((list) => list.map((t) => (t._id === updated._id ? updated : t)));    } catch (err) {      setError(err.message);    }  }   async function deleteTodo(id) {    try {      await request(`${API}/${id}`, { method: 'DELETE' });      setTodos((list) => list.filter((t) => t._id !== id));    } catch (err) {      setError(err.message);    }  }   return (    <main className="card">      <h1>🐳 MERN Todo</h1>       <form onSubmit={addTodo}>        <input          type="text"          value={title}          onChange={(e) => setTitle(e.target.value)}          placeholder="What needs doing?"          maxLength={200}        />        <button type="submit">Add</button>      </form>       {error && <p className="error">⚠️ {error}</p>}      {loading && <p className="muted">Loading…</p>}      {!loading && todos.length === 0 && <p className="muted">Nothing to do yet. Add one above!</p>}       <ul>        {todos.map((todo) => (          <li key={todo._id} className={todo.completed ? 'done' : ''}>            <input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo)} />            <span>{todo.title}</span>            <button className="delete" onClick={() => deleteTodo(todo._id)} aria-label="Delete">              ✕            </button>          </li>        ))}      </ul>    </main>  );}

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Host မပါတဲ့ relative URL ပါ။ Development မှာ Vite က ဖြေပြီး production မှာ nginx က ဖြေပါတယ်။ နှစ်ခုလုံးက backend ဆီ လွှဲပေးလို့ React code က backend ဘယ်မှာ ရှိလဲ သိစရာ မလိုပါဘူး။
  2. fetch က 400 ဒါမှမဟုတ် 500 response တွေမှာ throw မလုပ်ပါဘူး။ res.ok ကို ကိုယ်တိုင် စစ်ပြီး API ရဲ့ error message ကို ပြပါတယ်။
  3. Page ပွင့်တဲ့အခါ စာရင်းကို တစ်ခါ load လုပ်ပြီး ရောက်လာတဲ့အထိ “Loading…” ပြပါတယ်။
  4. API ပြန်ပေးတဲ့ object ကို သုံးပြီး todo အသစ်ကို စာရင်းရဲ့ ထိပ်ဆုံးမှာ ထည့်ပါတယ်၊ ဒါကြောင့် တကယ့် _id ပါပါတယ်။

သေးငယ်ပေမဲ့ ပြည့်စုံတဲ့ React app ပါ — todo တွေ load လုပ်၊ အသစ်ထည့်၊ ပြီးကြောင်း အမှန်ခြစ်၊ ဖျက်ပြီး error တွေကို တိတ်တဆိတ် fail မဖြစ်ဘဲ ပြပေးပါတယ်။

သန့်ရှင်းတဲ့ အမှောင်ရောင် ပုံစံအတွက် frontend/src/index.css ကို ဖန်တီးပါ—

css
* { box-sizing: border-box; }body {  margin: 0;  font-family: system-ui, sans-serif;  background: #0f172a;  color: #e2e8f0;  display: grid;  place-items: start center;  min-height: 100vh;  padding: 48px 16px;}.card { width: 100%; max-width: 480px; background: #1e293b; border-radius: 12px; padding: 24px; }h1 { margin: 0 0 16px; font-size: 1.5rem; }form { display: flex; gap: 8px; margin-bottom: 16px; }input[type='text'] { flex: 1; padding: 10px 12px; border-radius: 8px; border: 1px solid #334155; background: #0f172a; color: inherit; }button { padding: 10px 14px; border: 0; border-radius: 8px; background: #2496ed; color: white; cursor: pointer; }button.delete { background: transparent; color: #f87171; padding: 4px 8px; }ul { list-style: none; margin: 0; padding: 0; }li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid #334155; }li span { flex: 1; }li.done span { text-decoration: line-through; color: #64748b; }.error { color: #f87171; }.muted { color: #64748b; }

nginx.conf

frontend/nginx.conf ကို ဖန်တီးပါ—

nginx
server {    listen 80;    server_name _;     root /usr/share/nginx/html;    index index.html;     # Docker's built-in DNS server. Asking it on each request means nginx    # still finds the backend after that container is recreated.    resolver 127.0.0.11 valid=10s;     # Everything under /api goes to the backend container.    location /api/ {        # "backend" is the Compose SERVICE NAME, not localhost!        set $backend http://backend:4000;        proxy_pass $backend;         proxy_http_version 1.1;        proxy_set_header Host $host;        proxy_set_header X-Real-IP $remote_addr;        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;        proxy_set_header X-Forwarded-Proto $scheme;    }     # Vite puts a content hash in these file names, so browsers may cache them for a year.    location /assets/ {        add_header Cache-Control "public, max-age=31536000, immutable";    }     # Any other path returns index.html, so a React route survives a page refresh.    location / {        try_files $uri $uri/ /index.html;    }}

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. 127.0.0.11 က user-defined network ပေါ်က container တိုင်းထဲမှာ ရှိတဲ့ Docker ရဲ့ built-in DNS server ပါ။ ဒါကြောင့် nginx က request ရောက်လာတဲ့အခါ backend ကို ရှာတာမို့ backend container ကို IP အသစ်နဲ့ ပြန်ဖန်တီးပြီးနောက်လည်း ဆက်အလုပ်လုပ်ပါတယ်။
  2. backend က Compose service name ပါ။ ဒီမှာ localhost:4000 လို့ ရေးမိရင် request တွေက nginx container ကိုယ်တိုင်ဆီ ရောက်သွားပြီး 502 Bad Gateway ရပါလိမ့်မယ်။
  3. Vite က asset တစ်ခုချင်းရဲ့ file နာမည်မှာ hash ထည့်ပေးတာမို့ (index-BSuVuEZq.js) ပြောင်းလဲထားတဲ့ file က နာမည်အသစ် အမြဲရပါတယ်။ Browser တွေက တစ်နှစ်လုံး cache လုပ်ထားလို့ရပါတယ်။
  4. Single-page app အတွက်ပါ — path နဲ့ ကိုက်တဲ့ file မရှိရင် index.html ကို ပြန်ပေးပြီး route ကို React က ကိုင်တွယ်ပါစေ။ ဒါမပါရင် /about ကို refresh လုပ်တာနဲ့ 404 ရပါတယ်။

nginx က အလုပ်နှစ်ခု လုပ်ပါတယ် — static React file တွေကို serve လုပ်ပြီး reverse proxy အဖြစ် /api/ request တိုင်းကို backend container ဆီ လွှဲပေးပါတယ်။

Multi-stage Dockerfile

frontend/Dockerfile ကို ဖန်တီးပါ—

dockerfile
# ---------- Stage 1: install dependencies ----------FROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci # ---------- Stage 2: build the static site ----------FROM deps AS buildCOPY . .# Vite writes plain HTML, CSS and JS into /app/distRUN npm run build # ---------- Stage 3: serve it with nginx ----------FROM nginx:alpine AS production# Our server settings replace nginx's default siteCOPY nginx.conf /etc/nginx/conf.d/default.conf# Take ONLY the built files from stage 2: no Node.js, no node_modulesCOPY --from=build /app/dist /usr/share/nginx/htmlEXPOSE 80# The nginx image already has the right CMD, so we don't repeat it

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Stage 1 က build လုပ်ဖို့ လိုအပ်တဲ့ Vite (devDependency) အပါအဝင် dependency အားလုံး ကို install လုပ်ပါတယ်။ နောက်ပိုင်း development mode မှာ ပြန်သုံးလို့ရအောင် stage ကို နာမည်ပေးထားပါတယ်။
  2. Stage 2 က stage 1 ကနေ စပြီး source code ထည့်ကာ static file တွေကို build လုပ်ပါတယ်။
  3. Stage 3 က nginx image အသေးလေးကနေ အသစ် ပြန်စပါတယ်။ ယခင် stage တွေက အရာအားလုံး copy မကူးမချင်း ကျန်ခဲ့ပါတယ်။
  4. Build stage ထဲကနေ dist/ ကိုပဲ copy ကူးပါတယ်။ နောက်ဆုံး image မှာ Node.js လည်း မပါ၊ node_modules လည်း မပါပါဘူး။

Multi-stage build အတွက် ဥပမာ — စားသောက်ဆိုင်ရဲ့ မီးဖိုချောင်နဲ့ စားသောက်ခန်း။ ဓား၊ ဒယ်အိုးနဲ့ ရှုပ်ပွမှု အားလုံး (Node.js၊ Vite၊ package ရာချီ) က မီးဖိုချောင်ထဲမှာပဲ ကျန်ပါတယ်။ ပြီးစီးတဲ့ ပန်းကန် (static file တွေ) ပဲ စားသောက်ခန်း (nginx) ဆီ ထွက်လာပါတယ်။ ရလဒ်ကတော့ 200 MB ကျော်အစား 50 MB ခန့် ပဲရှိတဲ့ frontend image ပါ။

နောက်ဆုံးအနေနဲ့ frontend/.dockerignore ကို ဖန်တီးပါ—

dotenv
node_modulesdistnpm-debug.log.env.gitDockerfile.dockerignore

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Local မှာ build လုပ်ထားတဲ့ dist/ ကိုလည်း ignore လုပ်ပါ၊ image က ကိုယ်ပိုင် build လုပ်ပါတယ်။

docker-compose.yml — stack တစ်ခုလုံး file တစ်ခုထဲမှာ

ပထမဦးဆုံး setting file ကို ဖန်တီးပါ။ .env.example ကို .env အဖြစ် copy ကူးပြီး (cp .env.example .env) password ကို ပြောင်းပါ—

dotenv
# Copy this file to .env and change the values.# Use letters and numbers only in the password (no @ : / ?), because it goes into a URL.MONGO_USER=todo_adminMONGO_PASSWORD=change_me_123

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Password က MongoDB connection URL ထဲ ရောက်သွားပြီး အဲဒီမှာ @၊ :၊ / နဲ့ ? တို့က အထူးအဓိပ္ပာယ် ရှိပါတယ်။ ရိုးရှင်းအောင်ထားပါ၊ ဒါမှမဟုတ် URL-encode လုပ်ပါ။

Compose က docker-compose.yml ဘေးက .env ဆိုတဲ့ file ကို အလိုအလျောက် ဖတ်ပြီး YAML ထဲက ${MONGO_USER} နဲ့ ${MONGO_PASSWORD} ကို အစားထိုးပေးပါတယ်။ .env ကို .gitignore ထဲ ထည့်ပြီး .env.example ကိုပဲ commit လုပ်ပါ။

အခု အဓိက file ပါ။ Project root မှာ docker-compose.yml ကို ဖန်တီးပါ—

yaml
services:  # ---------- MongoDB ----------  mongo:    image: mongo:7    restart: unless-stopped    environment:      # Creates the admin user the FIRST time the volume is empty      MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:?set MONGO_USER in .env}      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:?set MONGO_PASSWORD in .env}    volumes:      - mongo-data:/data/db   # the database files live in a named volume    networks:      - app-net    healthcheck:      # "Can I ping the database?" Exit code 0 means healthy.      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]      interval: 10s      timeout: 5s      retries: 5      start_period: 20s    # No "ports:" on purpose: only containers on app-net can reach MongoDB.   # ---------- Express API ----------  backend:    build: ./backend    restart: unless-stopped    environment:      PORT: 4000      # "mongo" is the service name above. NOT localhost!      MONGO_URI: mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongo:27017/todos?authSource=admin    depends_on:      mongo:        condition: service_healthy   # wait until the healthcheck passes    networks:      - app-net    healthcheck:      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4000/api/health"]      interval: 10s      timeout: 5s      retries: 5      start_period: 10s   # ---------- React app served by nginx ----------  frontend:    build: ./frontend    restart: unless-stopped    ports:      - "8080:80"   # HOST:CONTAINER  ->  open http://localhost:8080    depends_on:      backend:        condition: service_healthy    networks:      - app-net networks:  app-net:    driver: bridge volumes:  mongo-data:

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Official MongoDB 7 image ပါ။ Database အတွက် Dockerfile မလိုပါဘူး။
  2. Container တစ်ခု crash ဖြစ်ရင် (ဒါမှမဟုတ် server reboot ဖြစ်ရင်) ကိုယ်တိုင် ရပ်ထားတာ မဟုတ်ရင် Docker က ပြန်စပေးပါတယ်။
  3. ${VAR:?message} က variable မရှိရင် MongoDB ကို password အလွတ်နဲ့ တိတ်တဆိတ် စတင်မယ့်အစား Compose ကို အဲဒီ message နဲ့ ရပ်စေပါတယ်။
  4. Named volume က database file တွေကို သိမ်းထားပါတယ်။ Container တွေ လာလိုက်သွားလိုက်ဖြစ်ပေမဲ့ data က ရှိနေဆဲပါ။
  5. Healthcheck က image ထဲမှာ ပါတဲ့ mongosh tool နဲ့ MongoDB ကို ping လုပ်ပါတယ်။ ping က login မလိုလို့ authentication ဖွင့်ထားရင်တောင် အလုပ်လုပ်ပါတယ်။
  6. MongoDB စတင်ဖို့ စက္ကန့်အနည်းငယ် လိုပါတယ် (ပထမအကြိမ် user ဖန်တီးနေချိန်မှာ ပိုကြာပါတယ်)။ ဒီအချိန်အတွင်း fail ဖြစ်တာတွေကို မရေတွက်ပါဘူး။
  7. Image တစ်ခု download လုပ်မယ့်အစား backend/Dockerfile ကနေ build လုပ်ပါ။
  8. Host က mongo — service name ပါ။ authSource=admin က user ကို admin database ထဲမှာ ဖန်တီးထားကြောင်း MongoDB ကို ပြောပြပါတယ်၊ MONGO_INITDB_ROOT_* က အဲဒီမှာ ထည့်ပေးတာပါ။
  9. ရိုးရိုး depends_on က mongo container စတင်တာ ကိုပဲ စောင့်ပါတယ်။ service_healthy ဆိုရင် backend က MongoDB healthcheck ကို ဖြေနိုင်တဲ့အထိ စောင့်ပါတယ်။
  10. Alpine image တွေမှာ wget ပါပါတယ် (curl မပါပါဘူး)။ ကိုယ့် container ထဲမှာ 127.0.0.1 ဆိုတာ backend ကိုယ်တိုင်ကို မှန်မှန်ကန်ကန် ဆိုလိုပါတယ်။
  11. Stack တစ်ခုလုံးမှာ publish လုပ်ထားတဲ့ port တစ်ခုတည်းပါ — HOST:CONTAINER။ http://localhost:8080 ကို ဖွင့်ပါ။
  12. Built-in DNS ပါတဲ့ user-defined bridge network ဖြစ်လို့ service တွေက တစ်ခုနဲ့တစ်ခု နာမည်နဲ့ ရှာတွေ့ပါတယ်။ (Compose က default network ကို ဘယ်လိုပဲ ဖန်တီးပေးမှာပါ၊ နာမည်ပေးထားတာက အိုင်ဒီယာကို မြင်သာအောင်ပါ။)
  13. Volume ကို ဒီမှာ ကြေညာထားလို့ Compose က ဖန်တီးပြီး စီမံပေးပါတယ်။ တကယ့်နာမည်က <project>_mongo-data ဖြစ်ပါလိမ့်မယ်။

ဒီ file တစ်ခုတည်းက architecture diagram တစ်ခုလုံးကို code အဖြစ် ရေးထားတာပါ — service သုံးခု၊ ဘယ်အစဉ်နဲ့ စတင်လဲ၊ တစ်ခုနဲ့တစ်ခု ဘယ်လိုရှာတွေ့လဲ၊ ဘယ် port က public ဖြစ်လဲနဲ့ data ဘယ်မှာ သိမ်းလဲ။

Run ပြီး စမ်းသပ်ကြည့်မယ်

Stack ကို စတင်ခြင်း

bash
cd mern-todocp .env.example .env                 # then edit the password(cd backend && npm install)          # creates backend/package-lock.json(cd frontend && npm install)         # creates frontend/package-lock.json docker compose up -d --build         # build the images and start everything in the backgrounddocker compose ps                    # all three should be "running", mongo and backend "(healthy)"

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Lock file တွေ ဖန်တီးဖို့ တစ်ခါပဲ လိုပါတယ်။ အဲဒီနောက် dependency တွေကို Docker က image ထဲမှာ install လုပ်ပေးပါတယ်။
  2. --build က file ပြောင်းထားတဲ့ image တွေကို ပြန် build လုပ်ပြီး -d က terminal ကို ပြန်ပေးပါတယ်။ ပထမဆုံး build က မိနစ်အနည်းငယ် ကြာပြီး နောက်ပိုင်း build တွေက layer caching ကြောင့် မြန်ပါတယ်။
  3. Service တစ်ခု healthy မဖြစ်သေးရင် စက္ကန့်အနည်းငယ် စောင့်ပြီး ထပ် run ကြည့်ပါ။ Frontend က backend healthy ဖြစ်ပြီးမှ စတင်ပါတယ်။

http://localhost:8080 ကို ဖွင့်ပါ။ Todo အနည်းငယ် ထည့်၊ တစ်ခုကို အမှန်ခြစ်၊ တစ်ခုကို ဖျက်ကြည့်ပါ။ 🎉 Container သုံးခုပါတဲ့ MERN app တစ်ခုကို run နေပါပြီ။

nginx ကနေတဆင့် API ကို စမ်းသပ်ခြင်း

bash
curl http://localhost:8080/api/health# {"status":"ok"} curl -X POST http://localhost:8080/api/todos \  -H "Content-Type: application/json" \  -d '{"title":"Learn Docker volumes"}'# {"title":"Learn Docker volumes","completed":false,"_id":"...", ...} curl http://localhost:8080/api/todos# [ ...every todo, newest first... ] curl -X POST http://localhost:8080/api/todos -H "Content-Type: application/json" -d '{}'# {"error":"Todo validation failed: title: Title is required"}   (HTTP 400)

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. ကိုယ့်ကွန်ပျူတာ ကနေဆိုရင် localhost:8080 က မှန်ပါတယ် — frontend container ရဲ့ publish လုပ်ထားတဲ့ port ပါ။ nginx က /api/... ကို backend ဆီ ဆက်ပို့ပေးပါတယ်။

ဒီ request တွေက browser နဲ့ လမ်းကြောင်းအတူတူ သွားပါတယ် — ကိုယ့်ကွန်ပျူတာ → nginx → backend → MongoDB။ နောက်ဆုံးတစ်ခုက validation error တွေ crash မဖြစ်ဘဲ ရှင်းလင်းတဲ့ 400 အဖြစ် ပြန်လာကြောင်း ပြပါတယ်။

Log တွေ ဖတ်ခြင်း

bash
docker compose logs backend          # everything the backend printeddocker compose logs -f backend       # follow live (Ctrl+C to stop following)docker compose logs --tail 50 mongo  # only the last 50 lines

Connected to MongoDB နဲ့ API listening on port 4000 ကို မြင်ရပါလိမ့်မယ်။ တစ်ခုခု မှားသွားတိုင်း log တွေက အမြဲ ပထမဆုံး ကြည့်ရမယ့်နေရာပါ။

Data မပျောက်ကြောင်း သက်သေပြခြင်း

bash
docker compose down        # stop and DELETE all three containers (the volume stays)docker compose up -d       # brand-new containers# reload http://localhost:8080: your todos are still there ✅ docker volume ls           # you'll see mern-todo_mongo-data # Look inside the database directly (use the values from your .env)docker compose exec mongo mongosh -u todo_admin -p change_me_123 \  --authenticationDatabase admin todos --eval "db.todos.find()"

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. down က container တွေနဲ့ network ကို ဖယ်ရှားပေမဲ့ named volume တွေကို မဖျက်ပါဘူး။ MongoDB ရဲ့ file တွေက mongo-data ထဲမှာ ကျန်ရှိနေပါတယ်။
  2. Run နေတဲ့ mongo container ထဲမှာ mongosh ကို run ပေးလို့ တကယ့် data ကို query လုပ်ကြည့်လို့ရပါတယ်။

ဒါက volume ရဲ့ အလုပ်ပါ — container တွေကို ဖျက်ပြီး ပြန်ဖန်တီးလိုက်ပေမဲ့ data က “external hard drive” ပေါ်မှာ ဆက်ရှင်သန်နေပါတယ်။

Hot reload ပါတဲ့ development mode

Code ပြင်တိုင်း image ပြန် build လုပ်တာက နေ့စဉ်အလုပ်အတွက် နှေးလွန်းပါတယ်။ Development မှာ file ကို ပြင်လိုက်တာနဲ့ ရလဒ်ကို ချက်ချင်း မြင်ချင်ပါတယ်။ အဲဒီအတွက် tool က bind mount ပါ — ကိုယ့်ကွန်ပျူတာပေါ်က folder တစ်ခုကို container နဲ့ နှစ်ဖက်စလုံး တိုက်ရိုက် မျှသုံးပေးပါတယ်။

ဥပမာ — File ကို email နဲ့ အပြန်အလှန် ပို့နေမယ့်အစား မျှသုံးထားတဲ့ Google Doc တစ်ခု။ ကိုယ့်ကွန်ပျူတာမှာ ရိုက်လိုက်တာနဲ့ container က ပြောင်းလဲမှုကို တစ်ချိန်တည်းမှာ မြင်ပါတယ်။

အဓိက file ဘေးမှာ docker-compose.dev.yml ကို ဖန်တီးပါ။ Development မှာ ကွာခြားတဲ့ အရာတွေကိုပဲ ရေးထားပါတယ်—

yaml
# Development overrides. Use together with docker-compose.yml:#   docker compose -f docker-compose.yml -f docker-compose.dev.yml up --buildservices:  mongo:    ports:      - "27017:27017"   # lets MongoDB Compass on your machine connect (dev only)   backend:    command: ["node", "--watch", "src/server.js"]   # restart when a file changes    environment:      NODE_ENV: development    volumes:      - ./backend/src:/app/src   # bind mount: your code, live inside the container    ports:      - "4000:4000"   # call the API directly with curl while developing   frontend:    build:      context: ./frontend      target: deps   # stop at the stage that has Node and node_modules    command: ["npm", "run", "dev"]    environment:      API_URL: http://backend:4000   # Vite's proxy target: the service name again      WATCH_POLLING: "false"         # set to "true" if edits don't trigger a reload    volumes:      - ./frontend:/app          # your code, live      - /app/node_modules        # ...but keep the container's own node_modules    ports: !override  # replace the 8080:80 list instead of adding to it      - "5173:5173"   # open http://localhost:5173

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Development မှာပဲ MongoDB ကို ကိုယ့်ကွန်ပျူတာဆီ publish လုပ်ပေးလို့ MongoDB Compass လို GUI က mongodb://todo_admin:...@localhost:27017/?authSource=admin ကို ချိတ်လို့ရပါတယ်။
  2. Image ရဲ့ CMD ကို override လုပ်ပါတယ် — src/ ထဲက file တစ်ခု ပြောင်းတိုင်း Node.js က server ကို restart လုပ်ပါတယ်။
  3. Bind mount ပါ — container ရဲ့ /app/src က ကိုယ့် backend/src folder ကိုယ်တိုင် ပါ။ File ကို save လိုက်တာနဲ့ backend က တစ်စက္ကန့်အတွင်း restart ဖြစ်ပါတယ်။
  4. Multi-stage Dockerfile ရဲ့ deps stage အထိပဲ build လုပ်ပါ — nginx မပါဘဲ Node.js နဲ့ node_modules ပဲ ပါပါတယ်။
  5. nginx အစား Vite dev server ကို run ပါ။
  6. vite.config.js က ဖတ်တဲ့ Vite ရဲ့ proxy target ပါ။ ထပ်ပြောရရင် — service name ပါ။
  7. ကိုယ့် frontend/ folder တစ်ခုလုံး container ထဲမှာ တိုက်ရိုက် ရှိနေပါတယ်။
  8. node_modules အပေါ်က anonymous volume ပါ။ ဒါမပါရင် အပေါ်က bind mount က container ထဲမှာ install လုပ်ထားတဲ့ package တွေကို ကိုယ့် local folder (အလွတ် ဖြစ်နိုင်သလို တခြား OS အတွက် build လုပ်ထားတာလည်း ဖြစ်နိုင်) နဲ့ ဖုံးကွယ်သွားပါလိမ့်မယ်။
  9. ပုံမှန်အားဖြင့် Compose က ဒုတိယ file ထဲက list item တွေကို ပထမ file ထဲကို ပေါင်းထည့် ပါတယ်။ !override က frontend ရဲ့ port တွေကို အစားထိုးလို့ development မှာ 5173 တစ်ခုပဲ publish ဖြစ်ပါတယ်။

Stack ကို file နှစ်ခုလုံး နဲ့ စတင်ပါ။ နောက်က file က ရှေ့က file ကို override လုပ်ပါတယ်—

bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build # open http://localhost:5173# edit frontend/src/App.jsx  -> the page updates instantly (Vite HMR)# edit backend/src/server.js -> the backend logs "restarting" and picks up the change

အခု အရင်လိုပဲ ချက်ချင်း feedback ရရင်း develop လုပ်နိုင်ပေမဲ့ service တိုင်းက production နဲ့ အတူတူ Linux container တွေထဲမှာ run နေဆဲပါ။ Teammate အသစ်တွေက Docker နဲ့ command တစ်ကြောင်းပဲ လိုပါတော့တယ်။

Command cheat sheet

bash
# ---------- Images ----------docker build -t name:tag .          # build an image from ./Dockerfiledocker images                       # list imagesdocker pull mongo:7                 # download an imagedocker rmi name:tag                 # delete an imagedocker history name:tag             # show its layers # ---------- Containers ----------docker run -d --name x -p 8080:80 image   # start a container in the backgrounddocker run --rm -it node:22-alpine sh     # throwaway interactive containerdocker ps                           # running containersdocker ps -a                        # all containers, including stoppeddocker logs -f x                    # follow a container's outputdocker exec -it x sh                # shell inside a running containerdocker stop x && docker rm x        # stop, then deletedocker inspect x                    # every detail, as JSONdocker stats                        # live CPU and memory use # ---------- Volumes & networks ----------docker volume ls                    # list volumesdocker volume rm name               # delete a volume (and its data!)docker network ls                   # list networksdocker network inspect name         # who is connected? # ---------- Compose (run next to docker-compose.yml) ----------docker compose up -d --build        # build and start everythingdocker compose ps                   # status and healthdocker compose logs -f backend      # follow one service's logsdocker compose exec backend sh      # shell inside a servicedocker compose restart backend      # restart one servicedocker compose up -d --build backend   # rebuild and restart one servicedocker compose down                 # stop and remove containers (keep volumes)docker compose down -v              # ...and delete volumes too (data loss!)docker compose config               # print the final merged config (great for debugging) # ---------- Cleaning up ----------docker system df                    # how much disk Docker usesdocker system prune                 # remove stopped containers, unused networks, dangling imagesdocker image prune -a               # remove every image not used by a container

ဒီစာရင်းကို အနားမှာ ထားပါ။ docker compose config ကို အထူးဖော်ပြချင်ပါတယ် — variable တွေ ဖြည့်ပြီး override file တွေ ပေါင်းပြီးနောက် Compose file ကို ထုတ်ပြပေးပါတယ်။ တစ်ခုခု ထူးဆန်းနေရင် Compose က တကယ် ဘာမြင်နေလဲ စစ်ကြည့်ပါ။

အဖြစ်များတဲ့ error တွေနဲ့ ပြင်နည်း

ECONNREFUSED 127.0.0.1:27017

အကြောင်းရင်း — backend က MongoDB ကို localhost (127.0.0.1) မှာ ရှာနေပါတယ်။ Container ထဲမှာ အဲဒါက database run မထားတဲ့ backend container ကိုယ်တိုင်ပါ။

ပြင်နည်း — service name ကို သုံးပါ — mongodb://...@mongo:27017/todos?authSource=admin။ Container အချင်းချင်း လိပ်စာတိုင်းမှာ အတူတူပါပဲ — backend:4000၊ localhost:4000 ကို ဘယ်တော့မှ မသုံးပါနဲ့။

Bind for 0.0.0.0:8080 failed: port is already allocated

အကြောင်းရင်း — ကိုယ့်ကွန်ပျူတာပေါ်က တစ်ခုခု (တခြား container၊ local dev server ဒါမှမဟုတ် local MongoDB) က အဲဒီ port ကို သုံးနေပြီးသားပါ။

ပြင်နည်း — docker ps ဒါမှမဟုတ် lsof -i :8080 (macOS/Linux) နဲ့ ရှာပြီး ရပ်ပါ၊ ဒါမှမဟုတ် mapping ရဲ့ host ဘက်ကိုပဲ ပြောင်းပါ — "8081:80"။ macOS မှာ port 5000 ကို AirPlay Receiver က ယူထားပါတယ်၊ ဒီ project က 4000 ကို သုံးတဲ့ အကြောင်းရင်းတစ်ခုပါ။

Cannot connect to the Docker daemon

အကြောင်းရင်း — Docker run မနေဘူး၊ ဒါမှမဟုတ် (Linux မှာ) ကိုယ့် user က docker group ထဲ မပါဘူး။

ပြင်နည်း — Docker Desktop ကို ဖွင့်ပြီး အဆင်သင့်ဖြစ်တဲ့အထိ စောင့်ပါ။ Linux မှာ sudo systemctl start docker ကို run ပြီး sudo usermod -aG docker $USER ပြီးရင် logout ထွက်ပြီး ပြန် login ဝင်ပါ။

npm ci: “The npm ci command can only install with an existing package-lock.json”

အကြောင်းရင်း — lock file မရှိဘူး၊ ဒါမှမဟုတ် package.json နဲ့ မကိုက်ညီတော့ဘူး။

ပြင်နည်း — အဲဒီ folder ထဲမှာ npm install ကို run ပါ (ဒါမှမဟုတ် docker run ... npm install --package-lock-only နည်းလမ်းကို သုံးပါ)၊ lock file ကို commit လုပ်ပြီး ပြန် build လုပ်ပါ။

MongoServerError: Authentication failed

အကြောင်းရင်း — MONGO_INITDB_ROOT_USERNAME နဲ့ MONGO_INITDB_ROOT_PASSWORD ကို volume အလွတ်ဖြစ်နေတဲ့ ပထမအကြိမ်မှာပဲ သုံးပါတယ်။ နောက်မှ .env ထဲမှာ password ပြောင်းလိုက်ရင် database မှာ user အဟောင်းပဲ ရှိနေဆဲပါ။

ပြင်နည်း — password အဟောင်းကို ပြန်ထည့်ပါ၊ ဒါမှမဟုတ် (data အရေးမကြီးရင်) docker compose down -v နဲ့ docker compose up -d ဖြင့် အသစ်ကနေ စပါ။ Password ထဲမှာ @ ဒါမှမဟုတ် : လို URL ကို ပျက်စေတဲ့ စာလုံးတွေ ရှိမရှိလည်း စစ်ပါ။

dependency failed to start: container mern-todo-mongo-1 is unhealthy

အကြောင်းရင်း — healthcheck တစ်ခု ဆက်တိုက် fail ဖြစ်နေလို့ Compose က သူ့ကို မှီခိုတဲ့ service တွေကို စတင်ဖို့ ငြင်းလိုက်ပါတယ်။

ပြင်နည်း — အဲဒီ service ရဲ့ log ကို ဖတ်ပါ — docker compose logs mongo။ docker inspect --format "{{json .State.Health}}" mern-todo-mongo-1 က နောက်ဆုံး healthcheck ရလဒ်တွေနဲ့ output ကို ပြပါတယ်။

nginx ကနေ 502 Bad Gateway

အကြောင်းရင်း — nginx run နေပေမဲ့ backend ကို ဆက်သွယ်လို့ မရပါဘူး။ Backend crash ဖြစ်သွားတာ၊ စတင်နေဆဲ ဖြစ်တာ၊ ဒါမှမဟုတ် proxy target မှားနေတာ (backend အစား localhost) ဖြစ်နိုင်ပါတယ်။

ပြင်နည်း — docker compose ps နဲ့ docker compose logs backend ကို ကြည့်ပါ။ nginx.conf မှာ http://backend:4000 လို့ ရေးထားပြီး port က backend ရဲ့ PORT နဲ့ ကိုက်ညီကြောင်း စစ်ပါ။

ပြင်ထားတဲ့ code တွေ မပေါ်လာဘူး

အကြောင်းရင်း — production mode မှာ code ကို build လုပ်ချိန်မှာ image ထဲ copy ကူးထားပါတယ်။ Container ကို restart လုပ်ရုံနဲ့ image အဟောင်းကိုပဲ ပြန် run ပါတယ်။

ပြင်နည်း — ပြန် build လုပ်ပါ — docker compose up -d --build။ Dev mode မှာဆိုရင် bind mount တွေ နေရာမှန်မှာ ရှိမရှိကို docker compose -f docker-compose.yml -f docker-compose.dev.yml config နဲ့ စစ်ပါ။

exec format error

အကြောင်းရင်း — image ကို တခြား CPU အတွက် build လုပ်ထားပါတယ်။ ပုံမှန် ဖြစ်ရပ်က — Apple Silicon Mac (arm64) ပေါ်မှာ build လုပ်ပြီး Intel/AMD server (amd64) ပေါ်မှာ run တာပါ။

ပြင်နည်း — server ရဲ့ platform အတွက် build လုပ်ပါ — docker buildx build --platform linux/amd64 ...၊ ဒါမှမဟုတ် server ပေါ်မှာပဲ build လုပ်ပါ။

no space left on device

အကြောင်းရင်း — image အဟောင်းတွေ၊ ရပ်ထားတဲ့ container တွေနဲ့ build cache တွေ မြန်မြန် စုပုံလာပါတယ်။

ပြင်နည်း — docker system df နဲ့ အသုံးပြုမှုကို ကြည့်ပြီး docker system prune (build cache အတွက် docker builder prune) ကို run ပါ။ ဘယ် volume ထဲမှာမှ လိုအပ်တဲ့ data မရှိကြောင်း သေချာမှပဲ --volumes ကို ထည့်ပါ။

Best practice များ၊ Docker Hub နဲ့ VPS ပေါ် deploy လုပ်ခြင်း

Best practice များ

  • Version ကို သတ်မှတ်ပါ — node:22-alpine၊ mongo:7။ Production မှာ latest ကို ဘယ်တော့မှ အားမကိုးပါနဲ့။
  • Image သေးသေးလေး — alpine ဒါမှမဟုတ် slim base တွေနဲ့ multi-stage build ကို သုံးပါ။ Image သေးလေ download မြန်လေ၊ patch လုပ်ရမယ့် package နည်းလေပါ။
  • Cache အတွက် အစဉ် — dependency file တွေ အရင်၊ RUN npm ci၊ ပြီးမှ code။
  • .dockerignore အမြဲထားပါ — node_modules၊ .env နဲ့ .git ပါရပါမယ်။
  • Root အဖြစ် မ run ပါနဲ့ — USER node။
  • Image ထဲမှာ secret မထည့်ပါနဲ့ — environment variable၊ ဘယ်တော့မှ commit မလုပ်တဲ့ .env file ဒါမှမဟုတ် Docker secrets နဲ့ run ချိန်မှာ ပေးပါ။
  • Container တစ်ခုမှာ process တစ်ခု — API၊ database နဲ့ web server တစ်ခုချင်းစီ ကိုယ်ပိုင် container ရှိလို့ သီးခြားစီ restart၊ scale နဲ့ log လုပ်လို့ရပါတယ်။
  • Healthcheck + depends_on: condition: service_healthy — service တွေ မှန်ကန်တဲ့ အစဉ်နဲ့ စတင်ပါတယ်။
  • တတ်နိုင်သမျှ နည်းနည်းပဲ ဖွင့်ပါ — public ဝင်ပေါက်ကိုပဲ ports ပေးပါ။ Database တွေက internal network ပေါ်မှာပဲ နေပါစေ။
  • stdout/stderr ကို log ထုတ်ပါ (ရိုးရိုး console.log) — docker logs နဲ့ hosting platform တိုင်းက output ကို စုဆောင်းနိုင်ပါတယ်။
  • SIGTERM ကို ကိုင်တွယ်ပြီး exec-form CMD ကို သုံးပါ — deploy လုပ်တဲ့အခါ request တွေ တစ်ဝက်တစ်ပျက် မပြတ်တောက်တော့ပါဘူး။

Image တွေကို Docker Hub ပေါ် push လုပ်ခြင်း

hub.docker.com မှာ အခမဲ့ account တစ်ခု ဖွင့်ပါ။ ကိုယ့် image တွေရဲ့ နာမည်က yourname/image:tag ဖြစ်ပါလိမ့်မယ် (yourname ကို ကိုယ့် Docker Hub username နဲ့ အစားထိုးပါ)။

bash
docker login                                   # enter your Docker Hub username and an access token # Build for the server's CPU (usually amd64), tag, and push in one stepdocker buildx build --platform linux/amd64 -t yourname/mern-todo-backend:1.0.0 --push ./backenddocker buildx build --platform linux/amd64 -t yourname/mern-todo-frontend:1.0.0 --push ./frontend

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. Password အစား access token (Account settings → Personal access tokens) ကို သုံးပါ။
  2. Apple Silicon Mac သုံးနေရင် အရေးကြီးပါတယ် — VPS server အများစုက amd64 ပါ။ --platform linux/amd64,linux/arm64 နဲ့ နှစ်မျိုးလုံးကို တစ်ပြိုင်နက် build လုပ်လို့ရပါတယ်။

Release တစ်ခုချင်းစီအတွက် တကယ့် version နံပါတ် (1.0.0၊ 1.0.1၊ ...) ကို သုံးပါ၊ ဒါမှ ယခင် version ကို အမြဲ ပြန်သွားလို့ရပါတယ်။

VPS ပေါ် deploy လုပ်ခြင်း

RAM 1–2 GB ရှိတဲ့ ဘယ် Linux server မဆို ရပါတယ် (DigitalOcean၊ Hetzner၊ Linode၊ AWS Lightsail စသည်)။ Server ပေါ်မှာ Docker တစ်ခုပဲ လိုပါတယ် — Node.js လည်း မလို၊ MongoDB install လုပ်စရာလည်း မလိုပါဘူး။

bash
# 1. Connect and install Dockerssh root@YOUR_SERVER_IPcurl -fsSL https://get.docker.com | sh # 2. Get the project (Compose file + configs) onto the servergit clone https://github.com/yourname/mern-todo.gitcd mern-todocp .env.example .env && nano .env      # set a STRONG password # 3. Build and start (or use the pushed images, see below)docker compose up -d --builddocker compose ps # 4. Allow only SSH and web traffic through the firewallufw allow OpenSSHufw allow 8080/tcpufw enable

တစ်ကြောင်းချင်း ရှင်းလင်းချက်

  1. .env file ကို server ပေါ်မှာ ဖန်တီးပြီး Git ထဲကို ဘယ်တော့မှ commit မလုပ်ပါဘူး။
  2. တကယ့် website အတွက်ဆိုရင် frontend ကို "80:80" နဲ့ map လုပ်ပြီး port 80 (HTTPS အတွက် 443 ပါ) ကို ခွင့်ပြုပါ။

အခု app က http://YOUR_SERVER_IP:8080 မှာ live ဖြစ်နေပါပြီ — ကိုယ့် laptop ပေါ်မှာ run ခဲ့သလို အတိအကျပါပဲ။ ဒါက Docker ရဲ့ ကတိကို လက်တွေ့ ပေးအပ်လိုက်တာပါ။

Server ပေါ်မှာ build လုပ်မယ့်အစား push လုပ်ထားတဲ့ image တွေကို deploy လုပ်ချင်ရင် docker-compose.yml ထဲက build: စာကြောင်းတိုင်းကို image: yourname/mern-todo-backend:1.0.0 (frontend အတွက်လည်း အလားတူ) နဲ့ အစားထိုးပါ။ ပြီးရင် release တစ်ခုက docker compose pull && docker compose up -d ပဲ ဖြစ်သွားပါတယ်။

နိဂုံးနဲ့ နောက်ထပ် ဘာလေ့လာမလဲ

“ငါ့စက်မှာတော့ အလုပ်လုပ်တယ်” ကနေ စခဲ့ပြီး နေရာတိုင်းမှာ အတူတူ run တဲ့ MERN application အပြည့်အစုံ တစ်ခုနဲ့ အဆုံးသတ်ခဲ့ပါပြီ။ လမ်းတစ်လျှောက် လေ့လာခဲ့တာတွေက—

  • အဓိက အိုင်ဒီယာ ခုနစ်ခု — Dockerfile၊ image၊ container၊ registry၊ volume၊ network နဲ့ Compose။
  • Layer caching၊ multi-stage build၊ .dockerignore နဲ့ non-root user ပါတဲ့ production Dockerfile တွေ ဘယ်လိုရေးမလဲ။
  • Container တွေ တစ်ခုနဲ့တစ်ခု service name နဲ့ ဘယ်လို ရှာတွေ့လဲ၊ container ထဲမှာ localhost က ဘာကြောင့် container ကိုယ်တိုင်ကို ဆိုလိုလဲ။
  • Healthcheck နဲ့ depends_on က stack ကို မှန်ကန်တဲ့ အစဉ်နဲ့ ဘယ်လို စတင်ပေးလဲ၊ volume တွေက data ကို ဘယ်လို ကာကွယ်ပေးလဲ။
  • Bind mount နဲ့ hot reload သုံးပြီး ဘယ်လို develop လုပ်မလဲ၊ အဖြစ်များတဲ့ error တွေကို debug လုပ်၊ Docker Hub ပေါ် push လုပ်ပြီး VPS ပေါ် deploy လုပ်နည်း။

ဆက်လေ့လာရမယ့် လမ်းပြမြေပုံ

  1. ကိုယ့် project တွေပေါ်မှာ လေ့ကျင့်ပါ။ ရှိပြီးသား app တစ်ခုကို Dockerize လုပ်ကြည့်ပါ။ တစ်ခုခု ပျက်အောင်လုပ်ပြီး log တွေ ဖတ်တာက ဒီဗဟုသုတကို စွဲမြဲစေတဲ့ နည်းလမ်းပါ။
  2. CI/CD — push လုပ်တိုင်း GitHub Actions နဲ့ image တွေကို အလိုအလျောက် build ပြီး push လုပ်ကာ command တစ်ကြောင်းနဲ့ deploy လုပ်ပါ။
  3. Image security — Docker Scout ဒါမှမဟုတ် Trivy နဲ့ image တွေကို scan လုပ်ပြီး credential တွေအတွက် Docker secrets ကို သုံးပါ။
  4. Compose Watch နဲ့ Dev Containers — ပိုချောမွေ့တဲ့ local workflow။
  5. Reverse proxy နဲ့ HTTPS — Caddy၊ Traefik ဒါမှမဟုတ် Let's Encrypt နဲ့ nginx။
  6. Observability — Prometheus၊ Grafana နဲ့ Loki ဖြင့် log နဲ့ metric တွေ စုဆောင်းပါ။
  7. Orchestration — server တစ်လုံး မလုံလောက်တော့တဲ့အခါ Kubernetes ကို လေ့လာပါ (Minikube ဒါမှမဟုတ် k3s နဲ့ စပါ)။ Pod၊ Deployment နဲ့ Service တွေက container အိုင်ဒီယာ တူတူပေါ်မှာ တည်ဆောက်ထားလို့ ရင်းနှီးနေသလို ခံစားရပါလိမ့်မယ်။

Docker ဆိုတာ နောက်ပိုင်း ထိတွေ့သမျှ project တိုင်းမှာ အကျိုးပြုမယ့် skill မျိုးပါ။ Todo app ကို တည်ဆောက်၊ တမင် ပျက်အောင်လုပ်၊ ပြန်ပြင်ပြီး ကိုယ်ပိုင် project တစ်ခုခုကို container ထဲ ထည့်ကြည့်ပါ။ Happy shipping! 🐳

Filed underdockerdevopsmernnodejsreactmongodb

Written byZin Min Htun (Wine Chanz)Senior Full-Stack Developer · Da Nang, VietnamSay hello at campMore entries