技技术学习庭院建造日志2026.10.18

把博客装进 Docker:Caddy、HTTPS 与一朵不会凋谢的花

从多阶段构建到自动证书,把 Astro 的静态产物交给 Caddy,让整座庭院在一台 VPS 上安稳地开花。

4 MIN READ1160 字

写到这里,庭院已经有了页面、转场、搜索和评论。它在我本地跑得很好,但一座只存在于 localhost:4321 的花园,不能算真正开花。

这一篇记录我如何把它装进 Docker,用 Caddy 自动申请证书,并让它能安静地活在一台小小的 VPS 上。

为什么不是「直接在服务器上 npm run build」

那样当然能跑。但你很快会遇到这些问题:

  • 服务器上 Node 版本和本地不一致,某个依赖突然编译失败;
  • 构建产物和系统文件混在一起,回滚时只能靠记忆;
  • 每次更新都要登录、拉代码、装依赖,容易忘步骤;
  • 想搬到另一台机器时,得重新踩一遍所有的坑。

容器不是银弹,但它能把「这台机器上刚好能跑」变成「哪里都能跑」。

双阶段:构建在 Node,运行在 Caddy

Astro 的最终产物是一堆静态文件。真正需要 Node 的时刻只有构建。因此镜像可以分成两段:

# syntax=docker/dockerfile:1

FROM node:22-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

ARG SITE_URL=https://shadow-garden.example.com
ARG PUBLIC_GISCUS_REPO=""
ARG PUBLIC_GISCUS_REPO_ID=""
ARG PUBLIC_GISCUS_CATEGORY="Announcements"
ARG PUBLIC_GISCUS_CATEGORY_ID=""
ARG PUBLIC_GITHUB_URL=""

ENV SITE_URL=$SITE_URL \
    PUBLIC_GISCUS_REPO=$PUBLIC_GISCUS_REPO \
    PUBLIC_GISCUS_REPO_ID=$PUBLIC_GISCUS_REPO_ID \
    PUBLIC_GISCUS_CATEGORY=$PUBLIC_GISCUS_CATEGORY \
    PUBLIC_GISCUS_CATEGORY_ID=$PUBLIC_GISCUS_CATEGORY_ID \
    PUBLIC_GITHUB_URL=$PUBLIC_GITHUB_URL

RUN npm run build

FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
EXPOSE 80 443

第一阶段的 Node 镜像只存在于构建缓存里,最终镜像里没有 node_modules、没有源码,只有 Caddy 和 dist。

最终镜像通常只有几十 MB。它不包含编译器,也就减少了攻击面。

Caddyfile:把 HTTPS 交给它

Caddy 最讨人喜欢的一点是自动 HTTPS。只要域名解析正确、80 和 443 端口可达,它就会自己申请、续期证书。

{
  email {$ACME_EMAIL}
}

{$SITE_DOMAIN} {
  encode zstd gzip
  root * /srv
  try_files {path} {path}/ /404.html
  file_server

  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options "nosniff"
    Referrer-Policy "strict-origin-when-cross-origin"
    X-Frame-Options "SAMEORIGIN"
    Permissions-Policy "camera=(), microphone=(), geolocation=()"
    -Server
  }

  @immutable path /_astro/*
  header @immutable Cache-Control "public, max-age=31536000, immutable"

  @html path *.html
  header @html Cache-Control "no-cache"

  handle_errors {
    rewrite * /404.html
    file_server
  }
}

几个细节:

  • {path} {path}/ 让无斜杠与带斜杠的路径都能回退到对应的 index.html。
  • Astro 的静态资源带内容哈希,放在 /_astro/* 下,因此可以放心设置一年缓存。
  • HTML 不缓存,确保部署后读者立刻拿到新版本。
  • -Server 移除响应头里 Caddy 的版本信息。

docker-compose:把运行参数留在外面

镜像里不应该写死域名和密钥。它们属于部署环境,不属于构建产物。

services:
  garden:
    build:
      context: .
      args:
        SITE_URL: https://${SITE_DOMAIN}
        PUBLIC_GISCUS_REPO: ${PUBLIC_GISCUS_REPO}
        PUBLIC_GISCUS_REPO_ID: ${PUBLIC_GISCUS_REPO_ID}
        PUBLIC_GISCUS_CATEGORY: ${PUBLIC_GISCUS_CATEGORY:-Announcements}
        PUBLIC_GISCUS_CATEGORY_ID: ${PUBLIC_GISCUS_CATEGORY_ID}
        PUBLIC_GITHUB_URL: ${PUBLIC_GITHUB_URL}
    image: shadow-garden:latest
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    environment:
      SITE_DOMAIN: ${SITE_DOMAIN}
      ACME_EMAIL: ${ACME_EMAIL}
    volumes:
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:

caddy_data 必须持久化,否则每次重启都像第一次见到这个域名,重新申请证书。Let’s Encrypt 对重复申请有频率限制,这不是可以反复试错的游戏。

部署流程应该短到可以背下来

git pull --ff-only
docker compose build
docker compose up -d
docker image prune -f

如果只是想改一篇文章,构建缓存会让第一阶段很快。Caddy 容器几乎瞬间切换,停机时间以秒计。

我喜欢把这几行写进 scripts/deploy.sh,并在前面加一个简单检查:

set -euo pipefail

test -f .env || { echo "缺少 .env"; exit 1; }
docker compose config >/dev/null
docker compose build
docker compose up -d
docker compose ps

set -euo pipefail 不能保证不出错,但能让错误在早期暴露。

几个踩过的坑

域名还没解析就启动。 Caddy 会不断重试申请证书,日志里一片红。正确顺序是先把 A / AAAA 记录指向服务器,再启动容器。

80 端口被旧的 Nginx 占用。 自动 HTTPS 需要 80 端口做 HTTP-01 验证。先停掉旧服务,确认 ss -ltnp 里没有别人。

在服务器上直接改代码。 容器的文件系统是临时的,docker compose up -d 一执行,手改的内容就消失了。所有改动都要回到仓库。

把 .env 提交进 Git。 证书邮箱、giscus ID 不是核弹密码,但也没有理由公开。把它放进 .gitignore。

一朵不会凋谢的花

容器化没有让博客变得更浪漫,但它让「维护」这件事变得可预期。

我知道下一次更新会发生什么:拉代码、构建、替换容器。失败了就回滚镜像,数据卷里的证书还在。花园的土壤和花的品种分开存放,季节更替时只需要换花。

部署不是终点,而是让写作可以继续的前提。

MARGINALIA

留言0

路过的话,留下一枚脚印吧。
BE KIND, BLOOM SOFTLY.

    还没有留言。做第一个在这里留下脚印的人吧。

    登录后会把留言记在你的名字下;不注册也可以直接留言。
    支持换行 · 最多 2000 字0 / 2000