Docker Compose 实战:搭建 Nginx + Node.js + PostgreSQL + Redis 完整开发环境
说明: 本文为配置思路与示例整理,不代表作者已在自己的服务器上逐项验证全部命令。执行涉及公网暴露、账户权限、数据删除或服务重启的操作前,请先备份,并结合官方文档与实际环境核验。
为什么需要这篇文章?
每次搭建新项目都要手动装 Nginx、配 Node.js、启 PostgreSQL、起 Redis……换台电脑又得重来一遍。有没有一种方式,一条命令就能把整个开发环境拉起来?
答案就是 Docker Compose。本文将手把手带你用 Docker Compose 搭建一个较完整的的 Web 开发环境,涵盖反向代理、应用服务、数据库和缓存四大组件。跟着做,完成配置后可在自己的环境中验证服务是否正常。
环境准备
前置要求:
- Linux 服务器或本地开发机(macOS/Windows 均可)
- Docker 20.10+
- Docker Compose V2(
docker compose命令)
验证安装:
docker --version
# Docker version 24.x.x 或更高
docker compose version
# Docker Compose version v2.x.x如果还没安装 Docker,可以用官方脚本一键安装:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# 重新登录终端生效项目结构
我们搭建的项目结构如下:
my-web-app/
├── docker-compose.yml # 核心编排文件
├── nginx/
│ └── nginx.conf # Nginx 配置
├── app/
│ ├── Dockerfile # Node.js 应用镜像
│ ├── package.json
│ └── server.js # 简单的 Express 应用
└── db/
└── init.sql # 数据库初始化脚本第一步:编写 Docker Compose 文件
这是整个项目的核心,docker-compose.yml:
# version 字段在 Docker Compose v2 中已废弃,可省略
services:
# ==================== Nginx 反向代理 ====================
nginx:
image: nginx:1.25-alpine
container_name: web-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- nginx-logs:/var/log/nginx
depends_on:
app:
condition: service_healthy
restart: unless-stopped
networks:
- webnet
# ==================== Node.js 应用 ====================
app:
build:
context: ./app
dockerfile: Dockerfile
container_name: web-app
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://appuser:apppass@postgres:5432/webapp
- REDIS_URL=redis://redis:6379
- PORT=3000
expose:
- "3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
restart: unless-stopped
networks:
- webnet
# ==================== PostgreSQL 数据库 ====================
postgres:
image: postgres:16-alpine
container_name: web-postgres
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: apppass
POSTGRES_DB: webapp
volumes:
- pg-data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d webapp"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- webnet
# ==================== Redis 缓存 ====================
redis:
image: redis:7-alpine
container_name: web-redis
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- webnet
# ==================== 数据卷 ====================
volumes:
pg-data:
driver: local
redis-data:
driver: local
nginx-logs:
driver: local
# ==================== 网络 ====================
networks:
webnet:
driver: bridge关键设计解析
1. depends_on + condition: service_healthy
很多人只知道 depends_on 控制启动顺序,但不知道 condition 参数。默认情况下 depends_on 只等容器启动,不等服务就绪。加上 condition: service_healthy 后,会等健康检查通过才启动下游服务:
# ❌ 错误:容器启动了但 PostgreSQL 还没 ready
depends_on:
- postgres
# ✅ 正确:等 PostgreSQL 健康检查通过
depends_on:
postgres:
condition: service_healthy2. expose vs ports
ports: "80:80"— 映射到宿主机,外部可访问expose: "3000"— 仅容器间通信,不暴露到宿主机
Node.js 应用不需要直接对外,通过 Nginx 转发即可,所以用 expose。
3. 命名卷 vs Bind Mount
# 命名卷(生产推荐)— 数据由 Docker 管理,性能好
volumes:
- pg-data:/var/lib/postgresql/data
# Bind Mount(开发推荐)— 直接挂载本地目录,方便调试
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro第二步:编写 Nginx 配置
nginx/nginx.conf:
events {
worker_connections 1024;
}
http {
upstream app_backend {
server app:3000;
}
server {
listen 80;
server_name localhost;
# 访问日志
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript;
# 反向代理到 Node.js
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
}
# 静态文件(如果有)
location /static/ {
alias /app/public/;
expires 30d;
}
}
}注意 upstream 中的 app — 这就是 Docker Compose 网络中的服务名。Docker 内置 DNS 会自动将 app 解析为容器 IP。
第三步:编写 Node.js 应用
app/Dockerfile:
FROM node:20-alpine
WORKDIR /app
# 先复制依赖文件(利用 Docker 缓存层)
COPY package*.json ./
RUN npm ci --only=production
# 再复制源码
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]app/package.json:
{
"name": "web-app",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.0",
"pg": "^8.11.0",
"redis": "^4.6.0"
}
}app/server.js:
const express = require("express");
const { Pool } = require("pg");
const { createClient } = require("redis");
const app = express();
app.use(express.json());
// PostgreSQL 连接池
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
// Redis 客户端
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.on("error", (err) => console.error("Redis error:", err));
// ==================== 路由 ====================
// 健康检查
app.get("/health", async (req, res) => {
try {
await pgPool.query("SELECT 1");
await redisClient.ping();
res.json({ status: "ok", db: "connected", cache: "connected" });
} catch (err) {
res.status(503).json({ status: "error", message: err.message });
}
});
// 写入数据(同时写库 + 缓存)
app.post("/api/items", async (req, res) => {
const { name, value } = req.body;
try {
const result = await pgPool.query(
"INSERT INTO items (name, value) VALUES ($1, $2) RETURNING *",
[name, value]
);
// 写入缓存
await redisClient.set(`item:${result.rows[0].id}`, JSON.stringify(result.rows[0]), { EX: 3600 });
res.json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 读取数据(优先走缓存)
app.get("/api/items/:id", async (req, res) => {
try {
// 先查缓存
const cached = await redisClient.get(`item:${req.params.id}`);
if (cached) {
return res.json({ source: "cache", data: JSON.parse(cached) });
}
// 缓存未命中,查数据库
const result = await pgPool.query("SELECT * FROM items WHERE id = $1", [req.params.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: "not found" });
}
// 回填缓存
await redisClient.set(`item:${req.params.id}`, JSON.stringify(result.rows[0]), { EX: 3600 });
res.json({ source: "database", data: result.rows[0] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ==================== 启动 ====================
async function start() {
await redisClient.connect();
console.log("Redis connected");
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`App running on port ${port}`));
}
start().catch(console.error);第四步:数据库初始化
db/init.sql:
CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 插入测试数据
INSERT INTO items (name, value) VALUES
('hello', 'world'),
('docker', 'compose'),
('nginx', 'reverse-proxy');这个文件放在 /docker-entrypoint-initdb.d/ 目录下,PostgreSQL 容器首次启动时会自动执行。
第五步:启动!
# 构建并启动所有服务
docker compose up -d --build
# 查看运行状态
docker compose ps
# 查看日志(实时跟踪)
docker compose logs -f app预期输出:
NAME IMAGE STATUS PORTS
web-nginx nginx:1.25-alpine Up (healthy) 0.0.0.0:80->80/tcp
web-app my-web-app-app Up (healthy)
web-postgres postgres:16-alpine Up (healthy) 0.0.0.0:5432->5432/tcp
web-redis redis:7-alpine Up (healthy) 0.0.0.0:6379->6379/tcp验证服务:
# 健康检查
curl http://localhost/health
# {"status":"ok","db":"connected","cache":"connected"}
# 写入数据
curl -X POST http://localhost/api/items \
-H "Content-Type: application/json" \
-d '{"name":"test","value":"hello from compose"}'
# 读取数据(第一次走数据库)
curl http://localhost/api/items/1
# {"source":"database","data":{"id":1,"name":"hello","value":"world",...}}
# 读取数据(第二次走缓存)
curl http://localhost/api/items/1
# {"source":"cache","data":{"id":1,"name":"hello","value":"world",...}}常用运维命令
# 停止所有服务(保留数据卷)
docker compose down
# 停止并删除数据卷(⚠️ 会丢数据)
docker compose down -v
# 重建单个服务
docker compose up -d --build app
# 进入容器调试
docker compose exec app sh
docker compose exec postgres psql -U appuser webapp
# 查看资源占用
docker compose stats
# 一键更新镜像并重启
docker compose pull && docker compose up -d5 个避坑指南
1. 容器间通信用服务名,不用 localhost
Docker Compose 创建了独立的 bridge 网络,容器之间通过服务名互通:
# ✅ 正确:用服务名
DATABASE_URL=postgresql://appuser:apppass@postgres:5432/webapp
# ❌ 错误:localhost 指向容器自己
DATABASE_URL=postgresql://appuser:apppass@localhost:5432/webapp2. 端口冲突排查
# 查看谁占用了 80 端口
sudo lsof -i :80
# 或
sudo ss -tlnp | grep :803. 数据库连接被拒:等服务就绪
如果应用先于 PostgreSQL 启动,会报 ECONNREFUSED。确保用 healthcheck + condition: service_healthy。
4. 修改配置后忘记重建
Docker 缓存了旧的配置。改了 Dockerfile 或 nginx.conf 后,必须加 --build:
docker compose up -d --build nginx # 重建 Nginx
docker compose up -d --build app # 重建 App5. 查不到日志?用 docker compose logs
# ❌ 容器名太长记不住
docker logs web-postgres
# ✅ 用 compose 命令,自动带上项目前缀
docker compose logs postgres
docker compose logs --tail=50 -f # 实时跟踪最近 50 行总结
通过这个实战项目,你学到了:
| 技术点 | 说明 |
|---|---|
| 服务编排 | 用 docker-compose.yml 定义多容器应用 |
| 健康检查 | healthcheck + condition 确保服务按序就绪 |
| 网络隔离 | 自定义 bridge 网络,服务名互通 |
| 数据持久化 | 命名卷保存 PostgreSQL 和 Redis 数据 |
| 反向代理 | Nginx 转发请求到后端应用 |
| 缓存策略 | Redis 缓存 + 数据库兜底的读写模式 |
一条 docker compose up -d,整个环境就绪。换电脑、换服务器,复制这个目录就能跑——这就是 Docker Compose 的魅力。
本文为 FlecBlog 技术分享系列文章,代码示例基于 Docker Compose V2 和 Alpine 镜像,适用于开发和测试环境。生产部署请根据实际需求调整资源限制、安全策略和备份方案。
评论
游客无需注册即可评论。
你提交的昵称、邮箱、网址和评论内容会保存在服务端,用于展示评论身份、接收回复及必要的安全审计。
浏览器会本地保存已填游客信息和评论草稿,方便下次免填。
回复提醒会通过站内消息和邮件通知。