<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Traefik on Zata-砸它</title><link>https://www.zata.cc/tags/traefik/</link><description>Recent content in Traefik on Zata-砸它</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><copyright>Example Person</copyright><lastBuildDate>Mon, 08 Jun 2026 13:53:45 +0800</lastBuildDate><atom:link href="https://www.zata.cc/tags/traefik/index.xml" rel="self" type="application/rss+xml"/><item><title>Docker 和 Traefik 一键安装脚本</title><link>https://www.zata.cc/p/docker-traefik-install-script/</link><pubDate>Mon, 08 Jun 2026 00:30:00 +0800</pubDate><guid>https://www.zata.cc/p/docker-traefik-install-script/</guid><description>&lt;img src="https://www.zata.cc/p/docker-traefik-install-script/images/index/index.png" alt="Featured image of post Docker 和 Traefik 一键安装脚本" />&lt;p>在单台 VPS 上部署多个 Web 服务时，我现在更倾向于先准备一个公共入口层：Docker 负责跑应用，Traefik 负责根据域名把请求转发到不同容器，并自动接入 HTTPS。&lt;/p>
&lt;p>这篇文章放一个可直接运行的脚本，用来在 Ubuntu/Debian 服务器上安装 Docker Engine、Docker Compose 插件，并启动一个基础 Traefik 网关。&lt;/p>
&lt;p>脚本文件和这篇文章放在同一个目录里，可以直接查看或下载：&lt;a class="link" href="install-docker-traefik.sh" >install-docker-traefik.sh&lt;/a>。&lt;/p>
&lt;h2 id="适用场景">适用场景
&lt;/h2>&lt;p>适合这些情况：&lt;/p>
&lt;ul>
&lt;li>一台新的 Ubuntu/Debian 云服务器。&lt;/li>
&lt;li>准备用 Docker Compose 部署多个站点或服务。&lt;/li>
&lt;li>想用 Traefik 统一管理 &lt;code>80&lt;/code>、&lt;code>443&lt;/code> 入口。&lt;/li>
&lt;li>不想再为每个应用单独写 Nginx 配置。&lt;/li>
&lt;/ul>
&lt;p>不适合这些情况：&lt;/p>
&lt;ul>
&lt;li>已经使用 Kubernetes Ingress。&lt;/li>
&lt;li>已经由 1Panel、Coolify、Dokploy 等平台完整接管反向代理。&lt;/li>
&lt;li>需要复杂的企业级网关策略，比如多租户权限、统一认证、灰度发布等。&lt;/li>
&lt;/ul>
&lt;h2 id="快速运行">快速运行
&lt;/h2>&lt;p>如果只是安装 Docker 和 Traefik，不需要立即配置 HTTPS：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">chmod +x install-docker-traefik.sh
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">./install-docker-traefik.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果要给真实网站签发 Let&amp;rsquo;s Encrypt 证书，建议填写 &lt;code>ACME_EMAIL&lt;/code>（&lt;strong>必须是真实邮箱&lt;/strong>，脚本会拒绝 &lt;code>you@example.com&lt;/code> 这类占位值，Let&amp;rsquo;s Encrypt 也只会用占位邮箱会让证书一直申请失败）：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ACME_EMAIL&lt;/span>&lt;span class="o">=&lt;/span>you@your-domain.com ./install-docker-traefik.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>脚本会把 Traefik 配置写到：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/opt/traefik
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Dashboard 默认只监听服务器本机：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">http://127.0.0.1:8080/dashboard/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>远程查看可以用 SSH 端口转发：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ssh -L 8080:127.0.0.1:8080 user@server
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="参数说明">参数说明
&lt;/h2>&lt;p>常用环境变量如下：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>变量&lt;/th>
&lt;th>默认值&lt;/th>
&lt;th>说明&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>TRAEFIK_DIR&lt;/code>&lt;/td>
&lt;td>&lt;code>/opt/traefik&lt;/code>&lt;/td>
&lt;td>Traefik 配置目录&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>TRAEFIK_NETWORK&lt;/code>&lt;/td>
&lt;td>&lt;code>traefik&lt;/code>&lt;/td>
&lt;td>公共 Docker 网络名&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>TRAEFIK_IMAGE&lt;/code>&lt;/td>
&lt;td>&lt;code>traefik:v3.7&lt;/code>&lt;/td>
&lt;td>Traefik 镜像版本&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>ACME_EMAIL&lt;/code>&lt;/td>
&lt;td>空&lt;/td>
&lt;td>Let&amp;rsquo;s Encrypt 邮箱，不填则不启用自动 HTTPS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>ENABLE_HTTPS_REDIRECT&lt;/code>&lt;/td>
&lt;td>自动判断&lt;/td>
&lt;td>填了 &lt;code>ACME_EMAIL&lt;/code> 时默认开启 HTTP 到 HTTPS 跳转&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>INSTALL_SAMPLE&lt;/code>&lt;/td>
&lt;td>&lt;code>false&lt;/code>&lt;/td>
&lt;td>是否启动 &lt;code>whoami&lt;/code> 测试服务&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>WHOAMI_HOST&lt;/code>&lt;/td>
&lt;td>&lt;code>whoami.localhost&lt;/code>&lt;/td>
&lt;td>只给测试服务使用，不是你的真实网站域名&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;code>ACME_EMAIL&lt;/code> 可以不填。不填时，脚本仍然会安装 Docker 和 Traefik，但不会配置 Let&amp;rsquo;s Encrypt 证书解析器。&lt;/p>
&lt;p>&lt;code>WHOAMI_HOST&lt;/code> 也可以不填。它只在 &lt;code>INSTALL_SAMPLE=true&lt;/code> 时用于测试服务，真实网站应该在自己的 &lt;code>docker-compose.yml&lt;/code> 里配置 Traefik labels。&lt;/p>
&lt;h2 id="真实网站接入方式">真实网站接入方式
&lt;/h2>&lt;p>真实网站容器需要加入同一个 Traefik 网络：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">networks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">traefik&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">external&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>然后给 Web 服务加 labels：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">site&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">your-site-image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restart&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">unless-stopped&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">networks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">traefik&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.enable=true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.site.rule=Host(`example.com`)&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.site.entrypoints=websecure&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.site.tls=true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.site.tls.certresolver=letsencrypt&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.services.site.loadbalancer.server.port=80&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">networks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">traefik&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">external&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这里的 &lt;code>example.com&lt;/code> 要替换成真实域名，并且 DNS 需要提前解析到这台服务器 IP。服务器安全组或防火墙也要放行 &lt;code>80&lt;/code> 和 &lt;code>443&lt;/code>。&lt;/p>
&lt;h2 id="后续如何做-cd">后续如何做 CD
&lt;/h2>&lt;p>这个脚本只解决服务器入口层：安装 Docker、启动 Traefik、准备公共网络。它不会负责“代码变了以后怎么自动发布”。真正的 CD 需要额外建立一条发布链路：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">push 代码
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; CI/CD 平台构建 Docker 镜像
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; 推送镜像到镜像仓库
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; SSH 到服务器
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; 更新应用 compose 的镜像 tag
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; docker compose pull &amp;amp;&amp;amp; docker compose up -d
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; Traefik 根据 labels 接管流量
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这里用 GitHub Actions 举例，但思路对 GitLab CI、Woodpecker CI、Gitea Actions 也一样。Traefik 不关心你用哪个 CI/CD 平台，它只关心最终跑起来的容器是否在同一个 Docker 网络里，并且有没有正确的 labels。&lt;/p>
&lt;h3 id="一每个项目准备自己的镜像">一、每个项目准备自己的镜像
&lt;/h3>&lt;p>每个要部署的项目都应该能被构建成一个 Docker 镜像。不同技术栈的 &lt;code>Dockerfile&lt;/code> 不一样，但对 Traefik 来说只需要满足两点：&lt;/p>
&lt;ol>
&lt;li>容器内部有一个 HTTP 服务端口，比如 &lt;code>80&lt;/code>、&lt;code>3000&lt;/code>、&lt;code>8080&lt;/code>。&lt;/li>
&lt;li>不要直接把业务容器的端口暴露到公网，公网入口交给 Traefik。&lt;/li>
&lt;/ol>
&lt;p>如果是静态站点，可以用 Nginx 承载构建产物：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span class="line">&lt;span class="cl">&lt;span class="k">FROM&lt;/span>&lt;span class="s"> nginx:alpine&lt;/span>&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">COPY&lt;/span> public /usr/share/nginx/html&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果是 Node、Python、Go、Java 后端，则按对应技术栈构建镜像，只要最后服务监听一个明确端口即可。&lt;/p>
&lt;h3 id="二服务器上每个项目一个目录">二、服务器上每个项目一个目录
&lt;/h3>&lt;p>建议每个应用单独放到 &lt;code>/opt/apps/&amp;lt;app-name&amp;gt;&lt;/code>：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo mkdir -p /opt/apps/my-app
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /opt/apps/my-app
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="组织方案选择">组织方案选择
&lt;/h4>&lt;p>每个项目目录里至少需要 &lt;code>.env&lt;/code>（存放部署变量）和 &lt;code>docker-compose.yml&lt;/code>（定义容器运行方式）。这两者的关系有几种组织方式：&lt;/p>
&lt;p>&lt;strong>方案 A：compose 文件直接放在项目目录（默认）&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/opt/apps/my-app/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── .env
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── docker-compose.yml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>简单直观，适合 compose 文件不常变更的项目。&lt;/p>
&lt;p>&lt;strong>方案 B：compose 文件集中管理&lt;/strong>&lt;/p>
&lt;p>如果多个项目的 compose 模板相似，想统一维护，可以把 compose 文件放到共享目录：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/opt/compose-templates/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── traefik-app.yml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── static-site.yml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">/opt/apps/my-app/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── .env
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>部署时用 &lt;code>-f&lt;/code> 指定模板路径：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">docker compose -f /opt/compose-templates/traefik-app.yml --env-file /opt/apps/my-app/.env up -d
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>CD 流水线里也这样写，每次更新 compose 模板时只需要改 &lt;code>/opt/compose-templates/&lt;/code> 下的文件。&lt;/p>
&lt;p>&lt;strong>方案 C：用脚本封装&lt;/strong>&lt;/p>
&lt;p>在项目目录下放一个启动脚本，把模板路径写死在脚本里：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>&lt;span class="c1"># /opt/apps/my-app/deploy.sh&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /opt/compose-templates
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker compose -f traefik-app.yml --env-file /opt/apps/my-app/.env &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$@&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这样部署命令变成：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">/opt/apps/my-app/deploy.sh up -d
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">./deploy.sh logs -f
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>脚本本身也可以加入检查、重试、通知等逻辑，适合有统一运维规范的项目。&lt;/p>
&lt;p>&lt;strong>方案 D：用 git 仓库管理 compose 文件&lt;/strong>&lt;/p>
&lt;p>如果 CD 流水线已经配好了 SSH 密钥，服务器可以直接用 git 操作。只需要 clone 一次，之后 CD 时 &lt;code>git pull&lt;/code> 拉取最新配置：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">git clone git@github.com:your-org/server-configs.git /opt/configs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>目录结构：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/opt/configs/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── compose.yml # git 管理
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── .env.example # 提交 git，真实值在服务器上配置
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">/opt/apps/my-app/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── .env # 不提交 git，只存在服务器
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>CD 部署时：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /opt/configs &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> git pull &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nb">cd&lt;/span> /opt/apps/my-app &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> docker compose -f /opt/configs/compose.yml --env-file .env up -d
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>.env&lt;/code> 里的密钥留在服务器本地，不进 git 仓库。compose 文件由 git 管理，可以 review 和回滚。&lt;/p>
&lt;p>&lt;strong>怎么选&lt;/strong>：如果 compose 模板基本固定、很少改，用默认的方案 A 就行。如果想统一管理模板、减少每个项目重复的 compose 内容，选方案 B 或 C。如果需要多人协作、版本控制和 PR review，选方案 D。&lt;/p>
&lt;p>下面以方案 A 为例继续说明。如果选了其他方案，记得相应调整后面的路径和命令。&lt;/p>
&lt;p>&lt;code>.env&lt;/code> 只放部署层变量：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">APP_IMAGE&lt;/span>&lt;span class="o">=&lt;/span>ghcr.io/your-org/my-app:initial
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">APP_HOST&lt;/span>&lt;span class="o">=&lt;/span>app.example.com
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">APP_PORT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">3000&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>docker-compose.yml&lt;/code> 写成通用模板：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">${APP_IMAGE}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restart&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">unless-stopped&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">networks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">traefik&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expose&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;${APP_PORT}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.enable=true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.my-app.rule=Host(`${APP_HOST}`)&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.my-app.entrypoints=websecure&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.my-app.tls=true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.my-app.tls.certresolver=letsencrypt&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.services.my-app.loadbalancer.server.port=${APP_PORT}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">networks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">traefik&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">external&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这里没有写 &lt;code>ports&lt;/code>。业务容器不需要直接占用宿主机端口，Traefik 会通过 &lt;code>traefik&lt;/code> 网络访问容器内部端口。&lt;/p>
&lt;p>如果你的 Traefik 没有启用 Let&amp;rsquo;s Encrypt，可以先用 HTTP：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.enable=true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.my-app.rule=Host(`${APP_HOST}`)&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.routers.my-app.entrypoints=web&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;traefik.http.services.my-app.loadbalancer.server.port=${APP_PORT}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>真实项目的数据库密码、API key 等运行时环境变量，建议放在另一个只存在服务器上的文件，例如 &lt;code>app.env&lt;/code>，然后在 compose 里引用：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">env_file&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">app.env&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>CD 流水线只更新 &lt;code>.env&lt;/code> 里的 &lt;code>APP_IMAGE&lt;/code>，不要覆盖服务器上的业务密钥文件。&lt;/p>
&lt;h3 id="三准备部署用户和-ssh-key">三、准备部署用户和 SSH key
&lt;/h3>&lt;p>生产环境建议创建专门的部署用户，不要长期用 &lt;code>root&lt;/code> 跑 CD：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo adduser --disabled-password --gecos &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span> deploy
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo usermod -aG docker deploy
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>加入 &lt;code>docker&lt;/code> 组后，需要重新登录这个用户，组权限才会生效。&lt;/p>
&lt;p>生成一把专门给 CD 用的 SSH key：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">ssh-keygen -t ed25519 -C &lt;span class="s2">&amp;#34;cd-my-app&amp;#34;&lt;/span> -f ./cd-my-app -N &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>公钥放到服务器：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo install -d -m &lt;span class="m">700&lt;/span> -o deploy -g deploy /home/deploy/.ssh
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cat ./cd-my-app.pub &lt;span class="p">|&lt;/span> sudo tee -a /home/deploy/.ssh/authorized_keys
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo chmod &lt;span class="m">600&lt;/span> /home/deploy/.ssh/authorized_keys
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo chown -R deploy:deploy /home/deploy/.ssh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>私钥内容放到 CI/CD 平台的 Secret 里。以 GitHub Actions 为例，进入：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">Repository -&amp;gt; Settings -&amp;gt; Secrets and variables -&amp;gt; Actions
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>添加：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">SERVER_HOST 服务器 IP 或域名
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">SERVER_USER deploy
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">SERVER_SSH_KEY cd-my-app 私钥完整内容
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>注意：私钥不要提交到 Git 仓库。公钥留在服务器的 &lt;code>authorized_keys&lt;/code>，私钥只放 CI/CD Secret。&lt;/p>
&lt;h3 id="四准备镜像仓库">四、准备镜像仓库
&lt;/h3>&lt;p>镜像仓库可以用 GitHub Container Registry、Docker Hub、Harbor、阿里云/腾讯云镜像仓库等。通用原则是：&lt;/p>
&lt;ul>
&lt;li>CI/CD 需要有 &lt;code>push&lt;/code> 权限，用来推送新镜像。&lt;/li>
&lt;li>服务器需要有 &lt;code>pull&lt;/code> 权限，用来拉取新镜像。&lt;/li>
&lt;li>CD 部署时尽量使用不可变 tag，例如 Git commit SHA。&lt;/li>
&lt;li>&lt;code>latest&lt;/code> 可以保留给人工查看，但不要只依赖 &lt;code>latest&lt;/code> 做生产发布。&lt;/li>
&lt;/ul>
&lt;p>如果使用私有镜像，先在服务器上用部署用户登录一次：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo -iu deploy
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;你的镜像仓库 token&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> docker login ghcr.io -u your-user --password-stdin
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>登录信息会保存在部署用户自己的 Docker 配置里。之后 CD 执行 &lt;code>docker compose pull&lt;/code> 时，就能拉取私有镜像。&lt;/p>
&lt;h3 id="五github-actions-通用-cd-模板">五、GitHub Actions 通用 CD 模板
&lt;/h3>&lt;p>下面这个模板做三件事：&lt;/p>
&lt;ol>
&lt;li>构建镜像。&lt;/li>
&lt;li>推送 &lt;code>latest&lt;/code> 和当前 commit SHA 两个 tag。&lt;/li>
&lt;li>SSH 到服务器，把 &lt;code>.env&lt;/code> 里的 &lt;code>APP_IMAGE&lt;/code> 改成当前 commit SHA 对应的镜像，然后重启应用。&lt;/li>
&lt;/ol>
&lt;p>保存为：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">.github/workflows/cd.yml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">CD&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">on&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">push&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">branches&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">main&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">workflow_dispatch&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">permissions&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">contents&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">read&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">packages&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">write&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">REGISTRY_HOST&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">IMAGE_NAME&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/your-org/my-app&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">APP_DIR&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/opt/apps/my-app&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">jobs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deploy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runs-on&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ubuntu-latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Checkout&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">actions/checkout@v6&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Set up Docker Buildx&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">docker/setup-buildx-action@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Login to registry&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">docker/login-action@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">registry&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">${{ env.REGISTRY_HOST }}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">username&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">${{ secrets.REGISTRY_USERNAME }}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">password&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">${{ secrets.REGISTRY_TOKEN }}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Build and push image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">docker/build-push-action@v7&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">context&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">push&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tags&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ${{ env.IMAGE_NAME }}:${{ github.sha }}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ${{ env.IMAGE_NAME }}:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deploy on server&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">SERVER_HOST&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">${{ secrets.SERVER_HOST }}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">SERVER_USER&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">${{ secrets.SERVER_USER }}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">SERVER_SSH_KEY&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">${{ secrets.SERVER_SSH_KEY }}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">IMAGE&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">${{ env.IMAGE_NAME }}:${{ github.sha }}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> mkdir -p ~/.ssh
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> printf &amp;#39;%s\n&amp;#39; &amp;#34;$SERVER_SSH_KEY&amp;#34; &amp;gt; ~/.ssh/deploy_key
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> chmod 600 ~/.ssh/deploy_key
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ssh-keyscan -H &amp;#34;$SERVER_HOST&amp;#34; &amp;gt;&amp;gt; ~/.ssh/known_hosts
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ssh -i ~/.ssh/deploy_key &amp;#34;$SERVER_USER@$SERVER_HOST&amp;#34; \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;cd &amp;#39;${APP_DIR}&amp;#39; &amp;amp;&amp;amp; \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> sed -i &amp;#39;s#^APP_IMAGE=.*#APP_IMAGE=${IMAGE}#&amp;#39; .env &amp;amp;&amp;amp; \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> docker compose pull &amp;amp;&amp;amp; \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> docker compose up -d --remove-orphans &amp;amp;&amp;amp; \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> docker image prune -f&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>对应需要在 GitHub Secrets 里添加：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">REGISTRY_USERNAME 镜像仓库用户名
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">REGISTRY_TOKEN 镜像仓库 token
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">SERVER_HOST 服务器 IP 或域名
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">SERVER_USER SSH 部署用户
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">SERVER_SSH_KEY SSH 私钥
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果使用 GitHub Container Registry，并且镜像属于当前仓库，构建推送阶段也可以用 &lt;code>${{ github.actor }}&lt;/code> 和 &lt;code>${{ secrets.GITHUB_TOKEN }}&lt;/code>。但服务器拉取私有镜像时，仍然需要能 &lt;code>pull&lt;/code> 该镜像的凭据，最清晰的做法是给服务器配置一份只读或低权限的 registry token。&lt;/p>
&lt;h3 id="六发布后检查和回滚">六、发布后检查和回滚
&lt;/h3>&lt;p>部署完成后，至少检查三处：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /opt/apps/my-app
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker compose ps
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker compose logs -f
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -I https://app.example.com
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果应用提供健康检查接口，可以在 workflow 里加一步：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl -fsS https://app.example.com/health
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>回滚的本质是把 &lt;code>APP_IMAGE&lt;/code> 改回旧镜像 tag：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /opt/apps/my-app
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sed -i &lt;span class="s1">&amp;#39;s#^APP_IMAGE=.*#APP_IMAGE=ghcr.io/your-org/my-app:old-commit-sha#&amp;#39;&lt;/span> .env
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker compose pull
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">docker compose up -d --remove-orphans
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>所以每次发布都用 commit SHA 做镜像 tag 很重要。它让你能明确知道当前服务器运行的是哪一次代码，也能快速回到上一个可用版本。&lt;/p>
&lt;h3 id="七多项目部署约定">七、多项目部署约定
&lt;/h3>&lt;p>单台服务器跑多个项目时，建议保持这些约定：&lt;/p>
&lt;ul>
&lt;li>每个项目一个目录：&lt;code>/opt/apps/&amp;lt;app-name&amp;gt;&lt;/code>。&lt;/li>
&lt;li>每个项目一个 compose：只管理自己的业务容器。&lt;/li>
&lt;li>所有需要公网 HTTP/HTTPS 的服务都加入同一个 &lt;code>traefik&lt;/code> 网络。&lt;/li>
&lt;li>每个项目使用不同的 router/service 名称，例如 &lt;code>blog&lt;/code>、&lt;code>api&lt;/code>、&lt;code>admin&lt;/code>。&lt;/li>
&lt;li>每个项目只在 labels 里声明自己的域名和内部端口。&lt;/li>
&lt;li>CD 流水线只更新项目目录，不要改 &lt;code>/opt/traefik&lt;/code>。&lt;/li>
&lt;/ul>
&lt;p>这样 Traefik 是稳定的公共入口，业务项目各自独立发布。新增项目时，只需要新建项目目录、写自己的 compose、配置 DNS、接入一条 CD workflow。&lt;/p>
&lt;h2 id="完整脚本">完整脚本
&lt;/h2>&lt;p>完整脚本不再内嵌在正文里，避免博客内容和实际可执行文件不同步。脚本文件放在这篇文章同目录：&lt;a class="link" href="install-docker-traefik.sh" >install-docker-traefik.sh&lt;/a>。&lt;/p>
&lt;h2 id="运行后检查">运行后检查
&lt;/h2>&lt;p>查看 Traefik 容器：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo docker compose --env-file /opt/traefik/.env -f /opt/traefik/docker-compose.yml ps
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>查看日志：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">sudo docker compose --env-file /opt/traefik/.env -f /opt/traefik/docker-compose.yml logs -f
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>测试服务可以这样启动（同样，&lt;code>ACME_EMAIL&lt;/code> 必须是真实邮箱）：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ACME_EMAIL&lt;/span>&lt;span class="o">=&lt;/span>you@your-domain.com &lt;span class="nv">INSTALL_SAMPLE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="nv">WHOAMI_HOST&lt;/span>&lt;span class="o">=&lt;/span>whoami.example.com ./install-docker-traefik.sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果 &lt;code>whoami.example.com&lt;/code> 能访问，说明 DNS、80/443、防火墙、Traefik 路由和证书申请基本都通了。&lt;/p>
&lt;h2 id="如果浏览器一直显示此网站的证书无效">如果浏览器一直显示&amp;quot;此网站的证书无效&amp;quot;
&lt;/h2>&lt;p>按本博客早期示例用过 &lt;code>ACME_EMAIL=admin@example.com&lt;/code> 之类占位邮箱的用户会撞到这个问题。Let&amp;rsquo;s Encrypt 静默拒绝占位邮箱，acme.json 一直不生成，Traefik 只能回落到自签证书。&lt;/p>
&lt;p>修法是直接把 &lt;code>traefik.yml&lt;/code> 里的 &lt;code>email:&lt;/code> 改成真实邮箱，不需要重装：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1) 把真实邮箱替换进 traefik.yml&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo sed -i &lt;span class="s1">&amp;#39;s#admin@example.com#you@your-domain.com#&amp;#39;&lt;/span> /opt/traefik/traefik.yml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># （如果之前是别的占位值，把 sed 第一个参数里的字面量对应替换）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2) 清掉任何残留的 acme.json，让 Traefik 重新申请&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo rm -f /opt/traefik/letsencrypt/acme.json
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo touch /opt/traefik/letsencrypt/acme.json
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo chmod &lt;span class="m">600&lt;/span> /opt/traefik/letsencrypt/acme.json
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 3) 重启 Traefik，让它从第一个 HTTPS 请求开始走真正的 LE 申请&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /opt/traefik &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> sudo docker compose restart traefik
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>修完后用下面命令验证证书已经换成真证书：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="p">|&lt;/span> openssl s_client -connect your-domain.com:443 -servername your-domain.com 2&amp;gt;/dev/null &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> openssl x509 -noout -issuer -subject -dates
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 应该看到 issuer 含 &amp;#34;Let&amp;#39;s Encrypt&amp;#34;，subject 是你的域名&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="注意事项">注意事项
&lt;/h2>&lt;ol>
&lt;li>脚本会按 Docker 官方安装方式添加 apt 仓库，并安装 &lt;code>docker-ce&lt;/code>、&lt;code>docker-ce-cli&lt;/code>、&lt;code>containerd.io&lt;/code>、&lt;code>docker-buildx-plugin&lt;/code>、&lt;code>docker-compose-plugin&lt;/code>。&lt;/li>
&lt;li>脚本会移除可能冲突的旧包，例如 &lt;code>docker.io&lt;/code>、旧版 &lt;code>docker-compose&lt;/code>、&lt;code>podman-docker&lt;/code>、&lt;code>containerd&lt;/code>、&lt;code>runc&lt;/code>。&lt;/li>
&lt;li>Traefik dashboard 使用 &lt;code>api.insecure: true&lt;/code>，但端口只绑定到 &lt;code>127.0.0.1:8080&lt;/code>，不要改成公网监听，除非你额外加认证。&lt;/li>
&lt;li>Traefik 通过只读方式挂载 &lt;code>/var/run/docker.sock&lt;/code>。这依然是高权限入口，生产环境要限制谁能创建带 Traefik labels 的容器。&lt;/li>
&lt;li>Let&amp;rsquo;s Encrypt HTTP challenge 需要 &lt;code>80&lt;/code> 端口能从公网访问，否则证书申请会失败。&lt;/li>
&lt;li>&lt;code>ACME_EMAIL&lt;/code> 必须是真实邮箱。脚本会拒绝 &lt;code>admin@example.com&lt;/code>、&lt;code>you@example.com&lt;/code>、&lt;code>*@example.com&lt;/code> 等占位值；不要被早期示例误导。&lt;/li>
&lt;/ol>
&lt;h2 id="参考">参考
&lt;/h2>&lt;ul>
&lt;li>&lt;a class="link" href="https://docs.docker.com/engine/install/ubuntu/" target="_blank" rel="noopener"
>Docker Engine Ubuntu 安装文档&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://docs.docker.com/engine/install/debian/" target="_blank" rel="noopener"
>Docker Engine Debian 安装文档&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://doc.traefik.io/traefik/providers/docker/" target="_blank" rel="noopener"
>Traefik Docker provider 文档&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://hub.docker.com/_/traefik" target="_blank" rel="noopener"
>Traefik Docker 官方镜像&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://docs.github.com/en/actions/tutorials/publish-packages/publish-docker-images" target="_blank" rel="noopener"
>GitHub Actions 发布 Docker 镜像&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://docs.github.com/en/actions/concepts/security/secrets" target="_blank" rel="noopener"
>GitHub Actions Secrets&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://docs.docker.com/guides/gha/" target="_blank" rel="noopener"
>Docker 的 GitHub Actions 指南&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>