把博客装进 Docker:Caddy、HTTPS 与一朵不会凋谢的花
从多阶段构建到自动证书,把 Astro 的静态产物交给 Caddy,让整座庭院在一台 VPS 上安稳地开花。
写到这里,庭院已经有了页面、转场、搜索和评论。它在我本地跑得很好,但一座只存在于 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.
还没有留言。做第一个在这里留下脚印的人吧。