Một website chạy bằng Docker Compose thường chỉ có vài thành phần: reverse proxy nhận request từ Internet, web app xử lý nghiệp vụ, database lưu dữ liệu và đôi khi có thêm Redis hoặc worker. Số lượng container không nhiều, nhưng chỉ cần đặt sai một mục ports hoặc networks, database có thể vô tình lộ ra Internet hoặc Nginx không thể tìm thấy backend sau lần deploy.
Cách thiết kế dễ kiểm soát nhất không bắt đầu từ câu hỏi “nên tạo bao nhiêu network”. Hãy bắt đầu từ các đường giao tiếp thực sự cần có:
- Người dùng cần đi tới reverse proxy qua cổng 80 và 443.
- Reverse proxy cần gọi web app qua cổng nội bộ của ứng dụng.
- Web app cần kết nối tới database và Redis.
- Database không cần nhận kết nối trực tiếp từ Internet.
Khi đã xác định rõ các luồng kết nối này, việc cấu hình Docker network trở nên rất đơn giản: chỉ gom những dịch vụ cần nói chuyện với nhau vào chung một mạng nội bộ, và không publish bất kỳ cổng nào ra bên ngoài nếu cổng đó không trực tiếp đón khách truy cập.
Mô hình 2 lớp: Chỉ có reverse proxy mở cổng đón traffic Internet; ứng dụng đóng vai trò cầu nối trung gian giữa frontend và backend network; database cùng Redis được giấu kín hoàn toàn ở mạng nội bộ phía sau.
Compose đã tạo network mặc định như thế nào?
Nếu file compose.yaml không khai báo networks, Docker Compose vẫn tạo một bridge network mặc định cho project. Mọi service trong project được nối vào network này và có thể tìm nhau bằng service name.
Ví dụ:
services:
proxy:
image: nginx:alpine
app:
image: example/web-app:1.0
db:
image: postgres:17Trong container proxy, hostname app sẽ được Docker DNS phân giải tới container của service app. Tương tự, ứng dụng kết nối PostgreSQL bằng hostname db, không phải localhost và cũng không phải IP hiện tại của container.
http://app:3000
postgresql://db:5432/appdbĐây là điểm quan trọng khi deploy lại. Container mới có thể nhận IP khác, nhưng service name vẫn giữ nguyên. Nếu bạn hard-code một địa chỉ như 172.20.0.5, kết nối có thể hoạt động hôm nay rồi hỏng sau lần docker compose up tiếp theo.
Default network đủ dùng cho một stack nhỏ khi mọi service được phép giao tiếp với nhau. Khi muốn hạn chế đường kết nối—chẳng hạn proxy không cần nhìn thấy database—hãy chia thành named network.
Container port và host port không phải cùng một thứ
Giả sử web app lắng nghe cổng 3000 bên trong container. Reverse proxy ở cùng Docker network gọi thẳng:
http://app:3000Bạn không cần publish cổng 3000 ra VPS chỉ để proxy kết nối được. Giao tiếp giữa hai service dùng container port.
Khai báo sau tạo thêm một host port:
services:
app:
ports:
- "3000:3000"Nếu không chỉ định địa chỉ host, Docker có thể tự động mở cổng đó trên tất cả network interface của VPS. Khi firewall và rule Docker iptables cho phép, người bên ngoài có thể đi thẳng tới ứng dụng, bỏ qua reverse proxy, TLS và những kiểm soát bạn đặt ở Nginx hoặc Caddy. Vấn đề rủi ro khi Docker bypass rule firewall của máy chủ đã được phân tích cụ thể trong bài Firewall VPS nên mở những cổng nào?.
Với website production, chỉ service thực sự nhận traffic từ bên ngoài mới nên dùng ports:
services:
proxy:
ports:
- "80:80"
- "443:443"
app:
# Không cần ports nếu proxy ở cùng network
db:
# Không publish 5432 ra hostNếu một công cụ chạy trực tiếp trên VPS cần truy cập ứng dụng nội bộ, bạn có thể bind cổng vào loopback:
services:
app:
ports:
- "127.0.0.1:3000:3000"Theo tài liệu Docker hiện tại, cách này giới hạn truy cập vào Docker host. Tuy nhiên, Docker Engine cũ hơn 28.0.0 từng có ngoại lệ khiến máy trong cùng mạng lớp 2 có thể chạm tới cổng bind vào localhost. Nếu đang vận hành phiên bản cũ, hãy kiểm tra phiên bản và kế hoạch nâng cấp thay vì coi loopback là lớp bảo vệ duy nhất.
Có cần khai báo expose không?
expose mô tả cổng mà service cung cấp cho các container khác, nhưng không publish cổng đó ra host:
services:
app:
expose:
- "3000"Đây là cách ghi chú hữu ích, nhưng không phải firewall giữa các container. Nếu image đã khai báo cổng bằng EXPOSE, hoặc ứng dụng đang lắng nghe trên một cổng khác, container cùng network vẫn có thể kết nối khi biết đúng cổng. Muốn tách đường giao tiếp, bạn phải tách network hoặc bổ sung kiểm soát ở ứng dụng.
Mô hình hai network cho website nhỏ
Mô hình dưới đây giúp reverse proxy không kết nối trực tiếp tới database, trong khi app vẫn nói chuyện được với cả hai phía:
services:
proxy:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
networks:
- frontend
depends_on:
- app
app:
image: example/web-app:1.0
networks:
- frontend
- backend
environment:
DATABASE_HOST: db
REDIS_HOST: redis
db:
image: postgres:17
networks:
- backend
volumes:
- db-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
networks:
- backend
networks:
frontend:
backend:
internal: true
volumes:
db-data:Luồng kết nối lúc này như sau:
- Internet đi vào
proxyqua 80/443. proxygọiapp:3000trênfrontend.appgọidb:5432vàredis:6379trênbackend.proxykhông nằm trongbackend, nên không có đường trực tiếp tới database hoặc Redis.dbvàrediskhông cóports, nên không tạo cổng public trên VPS.
internal: true làm backend network không có kết nối mặc định ra bên ngoài. Điều này phù hợp khi database và Redis chỉ cần phục vụ app. Nhưng cần hiểu đúng giới hạn: app đang nối cả frontend và backend, nên nó vẫn có thể ra ngoài qua frontend. internal không biến network thành một hệ thống phân quyền ứng dụng và không thay thế mật khẩu database, TLS hoặc cấu hình quyền.
Khi nào không nên đặt internal: true?
Một số service backend vẫn cần gọi ra ngoài. Worker có thể phải gửi email qua API, ứng dụng cần gọi cổng thanh toán, hoặc database cần đẩy backup tới object storage. Nếu service chỉ nối vào network internal, những kết nối này sẽ không hoạt động.
Có ba hướng xử lý:
- Nối service cần outbound vào thêm một network không internal.
- Đưa tác vụ cần Internet sang một service riêng và chỉ cấp network cần thiết.
- Không dùng
internal: true, nhưng vẫn tách network để kiểm soát service nào nhìn thấy service nào.
Đừng bật internal chỉ vì tên nghe “an toàn hơn”. Hãy đối chiếu với luồng dữ liệu thật, đặc biệt là backup, webhook, email và API bên ngoài.
Khi nào cần external network?
External network hữu ích khi reverse proxy và ứng dụng nằm trong hai Compose project khác nhau. Ví dụ, bạn có một stack Caddy dùng chung cho nhiều website, còn mỗi website có file Compose riêng.
Tạo network một lần:
docker network create web-proxySau đó, cả hai project cùng tham chiếu network đó:
networks:
web-proxy:
external: true
name: web-proxyDocker Compose không tự tạo external network. Nếu network chưa tồn tại hoặc tên không khớp, docker compose up sẽ báo lỗi. Vì vậy, external network phải nằm trong checklist provisioning của VPS, không nên chỉ tồn tại trong trí nhớ của người triển khai.
Không cần dùng external network khi mọi service nằm trong cùng một Compose project. Named network thông thường đã đủ và được Compose quản lý theo vòng đời của project.
Container không kết nối được: kiểm tra theo thứ tự nào?
Khi Nginx báo host not found in upstream, connection refused hoặc website trả lỗi 502 Bad Gateway sau deploy, đừng vội sửa firewall. Hãy kiểm tra theo bốn bước sau:
1. Xem cấu hình Compose sau khi merge và nội suy
docker compose config
docker compose config --networks
docker compose config --servicesLệnh này cho biết mô hình thực sự được áp dụng sau khi Compose merge các file -f, đọc biến môi trường và chuẩn hóa cú pháp. Đây là cách nhanh nhất để phát hiện service bị thiếu network do override production.
2. Xem container đang chạy và cổng đã publish
docker compose ps --all
docker compose port proxy 80Nếu output hiển thị 0.0.0.0:3000->3000/tcp cho app hoặc 0.0.0.0:5432->5432/tcp cho database, bạn đang publish cổng trên mọi địa chỉ host. Hãy xác nhận đây có thực sự là chủ đích.
3. Kiểm tra membership của network
docker network ls
docker network inspect ten-project_frontend
docker network inspect ten-project_backendHai service chỉ tìm được nhau trực tiếp khi chúng có ít nhất một network chung. Kiểm tra cả tên project vì Compose thường thêm prefix vào tên network.
4. Thử kết nối từ đúng container nguồn
docker compose exec proxy getent hosts app
docker compose exec proxy wget -qO- http://app:3000/health
docker compose exec app getent hosts dbKhông phải image nào cũng có curl, wget hoặc getent. Khi thiếu công cụ, dùng endpoint hoặc tiện ích đã có trong image; tránh cài thêm package trực tiếp vào container production rồi quên mất thay đổi đó không nằm trong Dockerfile.
Nếu DNS phân giải được app nhưng kết nối vẫn bị từ chối, vấn đề thường chuyển sang cổng ứng dụng, địa chỉ bind hoặc trạng thái process. Ứng dụng trong container phải lắng nghe trên interface phù hợp, thường là 0.0.0.0, thay vì chỉ 127.0.0.1 bên trong chính container.
Checklist trước khi đưa network lên production
- ☐ Chỉ reverse proxy hoặc service public có
ports. - ☐ Database, Redis và backend nội bộ không publish cổng ra mọi interface của VPS.
- ☐ Các service gọi nhau bằng service name và container port, không hard-code IP container.
- ☐ Proxy và app có một network chung.
- ☐ App và database/cache có một network chung.
- ☐ Proxy không nối backend network nếu không có lý do rõ ràng.
- ☐ Đã xem output của
docker compose configsau khi áp dụng file production. - ☐ Đã kiểm tra
docker compose psvà port mapping thực tế. - ☐ Đã thử DNS và kết nối từ đúng container nguồn.
- ☐ Nếu dùng external network, quy trình tạo network đã được ghi lại.
- ☐ Nếu dùng
internal: true, đã xác nhận nhu cầu gọi API, gửi email và đẩy backup ra ngoài.
Một Docker network tốt không cần phức tạp. Nó chỉ cần phản ánh đúng các đường giao tiếp của ứng dụng và không tạo thêm cửa vào không cần thiết. Khi service name, container port và ranh giới frontend/backend được thiết kế rõ ngay từ đầu, bạn sẽ tránh được cả hai thái cực: mở mọi cổng cho “dễ kết nối” hoặc tách mạng quá mức khiến mỗi lần deploy lại biến thành một buổi dò lỗi. Để rà soát đầy đủ từ mạng, volume đến cấu hình và phục hồi khi đưa ứng dụng lên máy chủ thực tế, hãy xem Checklist đưa Docker Compose lên production.



