本节摘要:Web 应用模板覆盖从静态站到动态站的全谱系:Nginx 挂卷跑静态文件、Flask 与 Django 的 Python 栈、Node.js 的 Express 栈,以及 Nginx 反向代理挂多个应用容器的组合形态。每种形态都给出完整 compose 配置、关键行解读、变体和坑位,重点处理 HTTPS 证书挂载与文件上传场景的卷权限问题。
Web 应用形态差别很大,先按"有没有后端逻辑"和"什么语言"两条线选起点:
静态站是 Web 应用里最省事的形态:只有 HTML、CSS、JavaScript,不需要运行时,Nginx 直接吐文件就行。这是本章最短的一张配方卡:
services: web: image: nginx:latest ports: - "80:80" volumes: - ./html:/usr/share/nginx/html:ro restart: always
逐行看:
image: nginx:latest:官方 Nginx 镜像,带默认配置,根目录指向 /usr/share/nginx/html。ports: - "80:80":宿主机 80 转发到容器 80。80 是特权端口,Linux 下 Docker 本身以 root 起容器不受限,但若宿主机 80 已被占用,改成 "8080:80" 即可。volumes: - ./html:/usr/share/nginx/html:ro:把当前目录下的 html 文件夹挂进容器默认站点根目录。尾部的 ro 是只读,容器只能读不能写,防止被攻破后篡改页面文件。这是我们团队的习惯写法,纯展示站一律加 ro。restart: always:容器异常退出时总是尝试重启。使用流程:建一个 html 目录放 index.html,把 docker-compose.yml 放在与 html 同级的位置,执行 docker compose up -d,浏览器访问宿主机 IP 即可。改页面文件后不用重启容器,Nginx 每次请求都会重新读盘,刷新浏览器就能看到新内容,这是挂载比把文件 COPY 进镜像方便的地方。
变体一:挂 nginx.conf 做压缩和缓存。 默认配置没有开启 gzip,图片多的站可以挂一份自定义配置:
services: web: image: nginx:latest ports: - "80:80" volumes: - ./html:/usr/share/nginx/html:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
nginx.conf 里写 gzip on; 与 gzip_types text/css application/javascript;,再配 expires 7d; 让浏览器缓存静态资源。注意挂载目标是 /etc/nginx/conf.d/default.conf,因为官方镜像的 /etc/nginx/nginx.conf 里已经 include 了这个目录下的所有 conf 文件,我们只需要替换默认站点配置。
变体二:SPA 应用。 Vue、React 构建产物是纯静态文件,但前端路由是 history 模式时,刷新子路径会 404。这时要加一条 try_files $uri /index.html; 的 location 规则,把不存在的路径全部回退到入口页。
变体三:错误页与目录暴露。 默认配置下访问不存在的路径返回 Nginx 的英文 404 页,观感差还泄露版本号;目录没有 index.html 时 Nginx 会列目录,等于把站点结构公开。两个问题一起处理:在 server 块加 error_page 404 /404.html; 指向自定义错误页,再补 autoindex off; 关闭目录列表。autoindex off 是官方默认值,但显式写出来能防止某次改配置时误开,我们习惯把这类安全默认值都写成显式配置。
Flask 是有后端逻辑的 Python 微框架,需要构建自定义镜像,同时在 compose 里声明它依赖的 Redis:
services: web: build: ./web ports: - "5000:5000" volumes: - ./web:/app environment: - FLASK_APP=app.py depends_on: - redis restart: always redis: image: redis:latest ports: - "6379:6379" restart: always
配套的 Dockerfile 放在 ./web 目录下:
FROM python:3.9-slim-buster WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD ["flask", "run", "--host=0.0.0.0"]
build: ./web:告诉 compose 从该目录的 Dockerfile 构建镜像,而不是拉现成的。FLASK_APP=app.py:Flask 命令行的入口文件变量;CMD ["flask", "run", "--host=0.0.0.0"] 里的 0.0.0.0 必须写,否则 flask 默认只听 127.0.0.1,容器外就访问不到了。volumes: - ./web:/app:开发期把代码目录挂进容器,改代码不用重新构建。配合 flask run 自带的 reloader,保存文件后进程自动重载。depends_on: - redis:只保证 Redis 容器先启动,不保证 Redis 已经就绪。如果应用启动时就连 Redis,可能撞上"服务起来了但还没监听"的窗口,可靠做法见 3.4 节的 service_healthy。应用代码里连 Redis 的主机名直接写服务名:
import redis, os redis_host = os.environ.get('REDIS_HOST', 'redis') r = redis.Redis(host=redis_host, port=6379, decode_responses=True) @app.route('/') def hello(): count = r.incr('hits') return f"Hello World! This page has been visited {count} times."
这段代码里有两个值得记住的细节:一是 REDIS_HOST 默认值写服务名 redis,compose 网络里服务名就是 DNS 名;二是 r.incr('hits') 是原子自增,用来做访问计数这类高频小数据时,比每次查数据库快两个数量级。
启动命令:首次构建要用 docker compose up --build -d,--build 强制重新构建镜像,之后代码变更靠挂载生效,不需要再 build。
环境变量的分层:FLASK_ENV 这类变量建议按环境拆到不同文件:开发用 .env.dev 置 development、测试用 .env.test 置 testing、生产置 production,compose 里统一写 ${FLASK_ENV} 引用。分层的好处是环境切换只换文件不换代码,也防止开发时误把调试开关带进生产。Flask 2.3 起官方更推荐 FLASK_DEBUG 控制调试,FLASK_ENV 管环境类别,两者配合使用,别混为一谈。
Django 和 Flask 同为 Python 栈,但启动方式不同。Django 自带开发服务器,生产形态却是 WSGI 进程,模板差异集中在两块:迁移命令和进程模型。
services: web: build: ./web ports: - "8000:8000" environment: - DJANGO_SETTINGS_MODULE=myproject.settings command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" volumes: - ./web:/app depends_on: - db db: image: postgres:15 environment: POSTGRES_USER: django POSTGRES_PASSWORD: django_pass POSTGRES_DB: myproject volumes: - db_data:/var/lib/postgresql/data volumes: db_data:
command 里把 migrate 和启动串在一起,保证每次容器起来都先同步表结构;开发期用 runserver 自带热重载,生产期换成 gunicorn 加 --workers 4 再配合静态文件收集 collectstatic。Django 的静态文件默认不由 runserver 提供,生产前要执行 collectstatic 并让 Nginx 托管,这条和第 5 节的组合正好衔接。
Express 是 Node.js 生态最常见的 Web 框架,模板结构上和 Flask 对称,数据库换成 MongoDB:
services: web: build: ./web ports: - "3000:3000" volumes: - ./web:/app environment: - NODE_ENV=development restart: always mongo: image: mongo:latest ports: - "27017:27017" volumes: - mongodb_data:/data/db restart: always volumes: mongodb_data:
Dockerfile 与 package.json 的关键行:
FROM node:16 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . CMD ["npm", "start"]
NODE_ENV=development:Express 据此启用开发模式,错误堆栈更完整,还影响 npm 依赖安装的裁剪逻辑。mongodb_data:/data/db:MongoDB 的数据目录,命名卷由 compose 管理,位置在 Docker 的卷存储区,删除容器不会丢数据,这也是数据库容器必须挂卷的原因。"express": "^4.17.1" 与 "mongoose": "^6.0.0",应用代码通过 mongoose.connect('mongodb://mongo:27017/mydatabase') 连接,连接串的主机名同样是服务名 mongo。单个应用直接映射端口没问题,但三四个应用就要占用三四个端口,记起来痛苦,跨域也麻烦。更常见的做法是让 Nginx 容器做唯一入口,按域名或路径把请求转发给后面的应用容器:
services: nginx: image: nginx:latest ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - flask_app - node_app flask_app: build: ./flask expose: - "5000" node_app: build: ./node expose: - "3000"
nginx.conf 里按 location 分流:
server { listen 80; location /api/ { proxy_pass http://flask_app:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://node_app:3000; } }
三个要点:一是上游地址直接写服务名 flask_app,compose 内置 DNS 会解析到对应容器 IP,不需要维护 IP 清单;二是应用容器用 expose 而不是 ports,expose 只在内部网络可见,不映射到宿主机,外部流量只能走 Nginx,少暴露一个口子;三是 proxy_set_header Host $host 必须带,否则后端拿不到真实域名,Flask 的 url_for 和 Django 的 ALLOWED_HOSTS 都会出问题。

内网玩可以一直用 HTTP,但一旦要对外提供页面,HTTPS 就是硬要求。Nginx 容器做 TLS 终止有两种证书来源,挂载方式不同:
自签或商业证书:把证书文件和私钥放在宿主机 certs 目录,挂载进容器,配置里引用:
services: nginx: image: nginx:latest ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./certs:/etc/nginx/certs:ro restart: always
nginx.conf 里对应写 listen 443 ssl;、ssl_certificate /etc/nginx/certs/fullchain.pem;、ssl_certificate_key /etc/nginx/certs/privkey.pem;。这里有个常见坑:证书文件权限太松,Nginx 启动时会警告私钥权限不安全但还能用;真正会挂的是路径写错或文件名拼错,容器直接起不来,docker compose logs nginx 会明确报出找不到文件。
Let's Encrypt 自动证书:证书有 90 天有效期,手动续期不现实。要么用 certbot 容器定时续期并 reload Nginx,要么换用 Traefik 这类自带 ACME 的反代。Traefik 的玩法是:compose 里给每个应用打标签,Traefik 读标签自动生成路由并申请证书,域名和邮箱写进 traefik.yml 的 certificatesResolvers 段,acme 存储文件挂到 /data 卷。它把"反代加证书"压缩成了几个标签行,代价是多学一套标签语法。
💡 证书更新后必须 reload:Nginx 只在启动时读一次证书,续期后不 reload 会继续用旧证书直到过期报错。certbot 的部署钩子或定时任务里要带上 docker exec nginx nginx -s reload。
这是 Web 模板里踩的人最多的坑。应用容器把上传目录挂到宿主机:
services: web: image: nginx:latest volumes: - ./html:/usr/share/nginx/html:ro - ./uploads:/usr/share/nginx/uploads
问题出在属主上:官方 Nginx 镜像的 worker 进程以 www-data 用户运行,UID 是 33;官方 Python 镜像默认以 root 跑,但生产里我们通常切到非 root 用户。宿主机上新建的 uploads 目录属主是当前用户(UID 往往是 1000),容器内用户对目录没有写权限,上传接口就报 403 或 500。
排查与解决按顺序试:先 docker compose exec web ls -ln /usr/share/nginx/uploads 看容器内视角的属主;再决定用 chown 33:33 uploads 把目录给 www-data,或在镜像 Dockerfile 里建同名 UID 的用户。更稳的做法是让应用容器和 Nginx 容器共享同一个上传卷,应用负责写、Nginx 负责读,两个容器约定同一个属主 UID,避免数据经宿主机目录中转时权限漂移。
⚠️ 上传目录别和站点根目录放一起:如果把 uploads 放在 html 里且允许执行脚本,攻击者上传一个带后门的文件就能直接访问执行。静态目录保持只读挂载,上传目录单独挂、单独控制访问。
反代模板能跑通只是起点,真实站点通常还要补三类配置,全写在挂载的 nginx.conf 里:
WebSocket 转发。前端实时推送、聊天、协作编辑都走 WebSocket,而 Nginx 默认不识别 Upgrade 请求头,握手会失败。在对应 location 里加两行:
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 60s;
proxy_read_timeout 60s 是给长连接的超时,WebSocket 场景默认的 60 秒要按业务调大,否则连接空闲一分钟后被 Nginx 掐断,前端表现为"经常掉线"。
上传大小限制。文件上传接口报 413 时,先查 Nginx 的默认限制:官方镜像默认 client_max_body_size 只有 1m。在 server 块里加 client_max_body_size 50m; 按业务调整。这个参数只管 Nginx 这层,应用层的限制(Flask 的 MAX_CONTENT_LENGTH、Django 的 DATA_UPLOAD_MAX_MEMORY_SIZE)是另一道闸,两层要一起改,只改一边都会遇到"前端明明传了,后端就是没收到"的怪现象。
多站点分流。一台机器挂多个域名时,一个 server 块配一个域名,Nginx 按 Host 头选块:
server { listen 80; server_name blog.example.com; root /usr/share/nginx/html/blog; } server { listen 80; server_name api.example.com; location / { proxy_pass http://flask_app:5000; } }
三个坑顺手记下:server_name 写错会导致请求落进默认 server 块,页面张冠李戴;proxy_pass 末尾带不带斜杠语义不同,带斜杠会丢弃 location 前缀,不带则原样透传,两个项目混用最容易在这里出 bug;反代后面挂着多个应用时,每个应用的上游名要和服务名一一对应,改 compose 里服务名时记得同步改 nginx.conf,不然 502 找上门。
HTTPS 强制跳转:证书配好后,80 端口的作用只剩跳转,server 块里写一条 return 301 指令,把请求永久重定向到 https 协议的同一路径。这样用户敲 http 地址也会被带到 https,避免"部分页面是明文"的割裂状态。301 是永久跳转,浏览器会缓存,调试期想回退 HTTP 要用 302 临时跳转,否则改完配置用户还停留在旧地址上。
| 子模板 | 镜像或构建 | 默认端口 | 关键环境变量 | 持久化位置 | 适用场景 |
|---|---|---|---|---|---|
| 静态站 | nginx:latest | 80 | 无 | ./html 挂载 | 文档、SPA 产物 |
| Flask | 自建 | 5000 | FLASK_APP | ./web 挂载 | 轻量 API |
| Django | 自建 | 8000 | DJANGO_SETTINGS_MODULE | ./web 挂载 | 内容管理类 |
| Node.js | 自建 | 3000 | NODE_ENV | mongodb_data 卷 | 实时交互类 |
| 反代组合 | nginx:latest | 80 | 无 | nginx.conf 挂载 | 多应用收口 |
./html:/usr/share/nginx/html:ro,页面更新不用重启容器下一节我们把目光转向所有 Web 应用都离不开的地基——数据库服务模板,看看三份主流数据库的 compose 配置有哪些共性套路。