Triển khai & Vận hành

Viết Dockerfile cho app web: ví dụ từng bước cho người mới

Tài liệu tạo thành khối container
Trong bài này

    Muốn app web của bạn chạy được ở bất cứ đâu, bạn cần đóng gói nó lại, và Dockerfile chính là tờ giấy ghi cách đóng gói đó. Trong bài này, bạn sẽ viết Dockerfile đầu tiên cho một máy chủ web nhỏ viết bằng Node.js (dùng Express), rồi tự build nó thành Image và chạy thử một Container trên máy của mình. Khi app chạy lên, bạn mở trình duyệt vào địa chỉ của nó sẽ thấy lời chào “Xin chào từ Docker!”.

    Trước khi bắt đầu, bạn kiểm tra xem Docker đã có trên máy chưa bằng lệnh sau trong Terminal:

    docker --version

    Nếu Terminal in ra một dòng như thế này thì Docker đã có trên máy bạn. Số phiên bản và mã build trên máy bạn sẽ khác, chỉ cần dòng đó bắt đầu bằng Docker version là được:

    Docker version 28.3.2, build 578ccf6

    Nếu Terminal báo không tìm thấy lệnh docker — trên Windows, câu báo lỗi bắt đầu bằng ‘docker’ is not recognized as an internal or external command…; trên macOS là command not found: docker — thì bạn cài đặt Docker theo hướng dẫn của Docker. Còn nếu lệnh docker --version chạy được mà lệnh docker khác lại báo không kết nối được, nghĩa là Docker Desktop chưa mở — bạn mở Docker Desktop lên, đợi nó khởi động xong rồi gõ lại lệnh.

    Bài này dùng các khái niệm trong hai bài trước: Docker là gì và bốn khái niệm Image, Container, Volume và Network, nên nếu bạn chưa đọc thì đọc hai bài đó trước, bài này sẽ dễ theo hơn nhiều.

    Dockerfile là gì?

    Nếu ví việc tạo Image như nấu một món ăn, thì Dockerfile là tờ công thức: mỗi dòng ghi một việc phải làm, và Docker cứ thế làm lần lượt từ dòng đầu tới dòng cuối.

    Mỗi khi một bước làm thay đổi file, như chép file vào hay chạy lệnh cài gói, Docker giữ lại kết quả của bước đó thành một lớp. Các lớp chồng lên nhau, và cả chồng lớp đó chính là Image.

    Lần build sau, bước nào không đổi thì Docker lấy lại kết quả cũ, khỏi chạy lại từ đầu. Chỉ khi bạn sửa một bước, Docker mới chạy lại từ bước đó trở đi. Chính vì vậy mà thứ tự các dòng trong Dockerfile rất quan trọng. Lát nữa, khi viết file, bạn sẽ thấy điều này quyết định chỗ đặt từng dòng.

    Mỗi dòng trong Dockerfile tạo thêm một lớp của image
    Các chỉ dẫn như RUN và COPY tạo ra các lớp của Image.

    Viết Dockerfile cho app web

    Giờ mình bắt tay vào viết. App ví dụ rất nhỏ, nên bạn chỉ cần vài file là đủ.

    App ví dụ gồm những file gì

    App ví dụ chỉ gồm hai file chính: package.json khai báo tên app và các gói cần dùng, còn server.js chạy một máy chủ web nhỏ. Khi làm xong cả bài, thư mục dự án của bạn sẽ trông như thế này:

    my-app/
    ├── Dockerfile
    ├── .dockerignore
    ├── .env
    ├── docker-compose.yml
    ├── package.json
    └── server.js

    Trong bài này, bạn sẽ tạo 4 file: package.json, server.js, Dockerfile và .dockerignore. Còn .env và docker-compose.yml trong cây thư mục thì thuộc về bài Compose, bạn chưa cần động tới chúng bây giờ.

    Tạo các file của app

    Bạn tạo một thư mục mới tên my-app rồi mở cửa sổ lệnh trong thư mục đó, vì mọi file và mọi lệnh trong bài đều nằm ở đây:

    mkdir my-app
    cd my-app

    Cách mở cửa sổ lệnh ngay trong thư mục my-app: trên Windows, bạn bấm chuột phải vào khoảng trống trong thư mục rồi chọn Open in Terminal; không thấy mục đó thì bạn gõ cmd vào thanh địa chỉ của File Explorer rồi nhấn Enter. Trên macOS, bạn bấm chuột phải vào thư mục my-app trong Finder rồi chọn New Terminal at Folder; nếu không thấy mục đó, bạn bật nó trong phần Services của cài đặt bàn phím, nhóm Files and Folders, hoặc mở Terminal rồi gõ lệnh cd tới thư mục đó.

    Từ đây trở đi, mọi lệnh trong bài đều chạy trong thư mục my-app, nên mỗi lần mở cửa sổ lệnh mới, bạn cd vào my-app trước rồi mới gõ lệnh.

    Bạn tạo các file bằng chương trình soạn thảo văn bản thuần túy trên máy của mình: Notepad trên Windows, TextEdit trên macOS (bạn nhớ chuyển sang chế độ văn bản thuần túy trước khi gõ: menu Format → Make Plain Text, hoặc phím tắt Shift + Command + T), hay VS Code nếu bạn đã cài sẵn.

    Trên Windows, khi lưu file Dockerfile, bạn nhớ chọn lưu dạng All files (hoặc bật hiện đuôi file trong File Explorer để kiểm tra). Nếu không, file của bạn sẽ thành Dockerfile.txt, và Docker không nhận file có đuôi .txt.

    Bắt đầu với file package.json:

    {
      "name": "web-app",
      "version": "1.0.0",
      "main": "server.js",
      "dependencies": {
        "express": "^4.19.2",
        "mysql2": "^3.11.0"
      }
    }

    File này khai báo app tên web-app, chạy từ file server.js, và dùng hai gói: express để chạy máy chủ web, còn mysql2 để kết nối MySQL.

    Tiếp theo là file server.js:

    const express = require("express");
    const mysql = require("mysql2/promise");
    const app = express();
    app.get("/", async (req, res) => {
      if (!process.env.DB_HOST) {
        return res.send("Xin chào từ Docker!");
      }
      try {
        const conn = await mysql.createConnection({
          host: process.env.DB_HOST,
          user: process.env.DB_USER,
          password: process.env.DB_PASSWORD,
          database: process.env.DB_NAME,
        });
        const [rows] = await conn.query("SELECT NOW() AS now");
        await conn.end();
        res.send("Xin chào từ Docker! Giờ trên MySQL: " + rows[0].now);
      } catch (err) {
        res.status(500).send("Chưa kết nối được database: " + err.message);
      }
    });
    app.listen(3000, () => console.log("App chạy ở cổng 3000"));

    Biến môi trường là cách đưa cấu hình vào app từ bên ngoài, thay vì ghi cứng trong code. Ở đây, app nhìn vào biến môi trường để biết có thông tin database không: nếu có, nó kết nối MySQL và trả về giờ hiện tại từ database; nếu không, nó chỉ trả về lời chào. Bạn không cần hiểu hết từng dòng code. Chỉ cần biết file này nhận yêu cầu ở cổng 3000 và đọc cấu hình từ biến môi trường.

    Viết Dockerfile

    Giờ đến phần chính. Bạn tạo một file tên Dockerfile (không có đuôi) trong cùng thư mục, với 7 dòng sau:

    FROM node:26-alpine
    WORKDIR /app
    COPY package*.json ./
    RUN npm install
    COPY . .
    EXPOSE 3000
    CMD ["node", "server.js"]
    • FROM node:26-alpine: lấy Image nền node:26-alpine, một Image đã cài sẵn Node.js 26, làm điểm bắt đầu cho mọi bước sau.
    • WORKDIR /app: tạo thư mục /app trong Image rồi đặt nó làm thư mục làm việc, để các lệnh sau đều chạy ở đó.
    • COPY package*.json ./: chép package.json vào Image; dấu sao để chép luôn cả package-lock.json nếu có file đó.
    • RUN npm install: chạy lệnh cài các gói đã khai báo trong package.json, ngay lúc build, nên khi build xong, Image đã có đủ thứ app cần.
    • COPY . .: chép nốt phần code còn lại vào Image.
    • EXPOSE 3000: ghi chú rằng app nghe ở cổng 3000. Đây chỉ là ghi chú, nó không tự mở cổng ra ngoài. Muốn mở cổng thì phải dùng -p lúc chạy, lát nữa bạn sẽ thấy.
    • CMD ["node", "server.js"]: lệnh chạy mỗi khi Container khởi động: mở app bằng Node.js với file server.js.

    Vì sao không chép hết code ngay từ đầu cho gọn? Nhờ cơ chế lớp ở trên: bước npm install là bước tốn thời gian nhất, mà nó chỉ phụ thuộc vào package.json. Chép package.json riêng trước, thì lần sau bạn chỉ sửa code, Docker sẽ dùng lại kết quả của bước npm install và chỉ chạy lại từ bước COPY . . trở đi. Còn nếu chép hết code ngay từ đầu, mỗi lần sửa một dòng code bạn lại phải chờ cài lại toàn bộ gói.

    File .dockerignore

    Trong cùng thư mục my-app, bên cạnh Dockerfile, bạn cần tạo thêm file .dockerignore. File này liệt kê những thứ Docker không được chép vào Image:

    node_modules
    npm-debug.log
    .git
    .env

    Trong bài này, trên máy bạn chưa có thư mục node_modules. Nó chỉ xuất hiện nếu bạn chạy npm install trên máy của mình. Nhưng file này vẫn cần có từ bây giờ: chỉ cần một lần bạn chạy npm install trên máy, thư mục node_modules (nơi chứa các gói đã cài) sẽ xuất hiện, và lần build sau lệnh COPY . . sẽ chép cả nó vào Image nếu không có file loại trừ. Hai dòng còn lại cũng vậy: npm-debug.log là file log khi npm gặp lỗi, còn .git là thư mục lưu lịch sử thay đổi của code. Cả hai đều không cần có trong Image. Thiếu file .dockerignore, lệnh COPY . . sẽ chép cả thư mục node_modules nặng nề vào Image, làm Image nặng hơn và build chậm hơn mà không được gì thêm.

    File .env sẽ giữ mật khẩu database trong bài Compose. Mật khẩu không bao giờ được nằm trong Image. Docker Compose sẽ đọc nó từ file .env trên máy của bạn và đưa vào Container lúc chạy.

    Build Image và chạy thử

    Có Dockerfile rồi, giờ mình build nó thành Image. Bạn mở cửa sổ lệnh tại thư mục dự án (nơi có Dockerfile) rồi chạy:

    docker build -t web-app .

    Lệnh này build Image từ Dockerfile trong thư mục hiện tại. Chữ -t là viết tắt của --tag, tức là bạn đặt tên cho Image, ở đây là web-app; vì bạn không ghi số phiên bản sau tên nên Docker mặc định gắn nhãn latest. Dấu chấm ở cuối nghĩa là lấy thư mục hiện tại làm nguồn để build. Khi build, Docker in từng bước theo thứ tự, bước nào xong thì báo xong; lần build đầu có thể mất một lúc vì Docker phải tải Image nền node:26-alpine về máy. Build xong, Docker báo đã tạo xong Image tên web-app.

    Build xong, bạn chạy thử một Container từ Image đó:

    docker run --rm --init -p 3000:3000 --name web web-app
    • --rm: tự xóa Container sau khi dừng, để lần sau chạy lại không bị trùng tên.
    • --init: nhờ Docker chèn một chương trình init nhỏ (tini) làm tiến trình số 1 trong Container. Nhờ có nó mà tín hiệu dừng như Ctrl+C mới tới được app, và các tiến trình con đã kết thúc được dọn sạch.
    • -p 3000:3000: ánh xạ cổng 3000 trên máy bạn sang cổng 3000 trong Container. Số đầu là cổng trên máy bạn, số sau là cổng trong Container.
    • --name web: đặt tên Container là web.
    • web-app: tên Image cần chạy, chính là Image bạn vừa build.

    Chừng nào app còn chạy, cửa sổ lệnh này sẽ bận, bạn không gõ thêm lệnh khác vào đó được. Muốn gõ lệnh khác thì bạn mở thêm một cửa sổ lệnh mới.

    Bây giờ bạn mở trình duyệt và vào localhost:3000, bạn sẽ thấy dòng chữ “Xin chào từ Docker!”.

    App chỉ chào vậy thôi, vì bạn chưa đặt biến môi trường nào cho nó nên đoạn code nối MySQL không chạy. Đoạn đó sẽ chạy ở bài Compose, khi các biến môi trường đã có đủ.

    Muốn dừng app, bạn chỉ cần bấm Ctrl+C trong cửa sổ lệnh đang chạy app, hoặc mở một cửa sổ lệnh khác và chạy docker stop web. Chữ web ở đây chính là tên bạn đã đặt bằng --name web ở lệnh chạy. Quên tên Container thì bạn chạy docker ps để xem tên và mã định danh (ID) của các Container đang chạy. Bạn cũng có thể dừng Container bằng mã định danh thay cho tên, ví dụ docker stop <ID>. Lệnh docker stop không cần chạy trong thư mục my-app; chỉ hai lệnh build và run mới cần chạy đúng thư mục dự án.

    Lỗi thường gặp khi mới viết Dockerfile

    Làm theo các bước trên, bạn có thể gặp ba lỗi sau. Cách sửa đều ngắn, nên bạn cứ đối chiếu với điều mình gặp rồi làm theo.

    • Quên tạo .dockerignore. Docker chép cả node_modules và các file thừa vào Image, làm Image nặng và build chậm. Bạn tạo file .dockerignore với đúng 4 dòng như trên rồi build lại.
    • Lệnh COPY sai đường dẫn. Docker báo lỗi, thường có chữ COPY failed kèm tên file nó không tìm thấy. Bạn kiểm tra lại đường dẫn trong lệnh COPY, và chắc chắn mình đang chạy lệnh trong đúng thư mục dự án, nơi có Dockerfile.
    • Cổng 3000 đã bị chương trình khác dùng. Khi chạy lệnh run, Docker báo lỗi và Container không khởi động được. Bạn sẽ thấy câu lỗi có đoạn Bind for 0.0.0.0:3000 failed: port is already allocated hoặc bind: address already in use. Cả hai đều nghĩa là cổng 3000 trên máy bạn đã có chương trình khác dùng. Cách nhanh nhất là đổi sang cổng khác: thay số đầu trong -p 3000:3000 thành 3001:3000 (cổng 3001 trên máy bạn trỏ tới cổng 3000 trong Container), chạy lại lệnh, rồi mở trình duyệt ở localhost:3001 thay vì localhost:3000. Nếu biết chương trình nào đang chiếm cổng thì bạn tắt nó đi cũng được.

    Câu hỏi thường gặp

    Dockerfile có phải là Image không?

    Không. Dockerfile là file văn bản ghi các chỉ dẫn; Image là kết quả Docker tạo ra sau khi làm theo từng chỉ dẫn trong file đó.

    Kết luận

    Xong bài này, bạn đã có Dockerfile đầu tiên của mình, một Image mang tên web-app và một Container chạy được app đó trên máy của bạn.

    Bài tiếp theo trong loạt bài này sẽ lấy chính app và Dockerfile này để chạy cả app và MySQL cùng lúc bằng Docker Compose, đồng thời giữ dữ liệu trong Volume để dữ liệu không mất khi Container dừng: Docker Compose cho app web kèm database.

    Nguồn tham khảo

    Các liên kết dưới đây đã được kiểm chứng ngày 05/11/2026.

    Gnurt

    Lập trình viên full-stack

    Trao đổi về dự án của bạn

    Bình luận

    Chưa có bình luận nào. Hãy là người đầu tiên!