feat(bootstrap): caddy public ingress + DNS-01 TLS (T07)

foundation-caddy — the only public ingress (80/443 published), automatic TLS via
Let's Encrypt DNS-01 over Cloudflare. Standard caddy:2 lacks the DNS plugin, so
the egg builds a custom image on the VM (containers/caddy-cloudflare/Dockerfile:
xcaddy + caddy-dns/cloudflare@v0.2.4, base digests pinned) via a remote.Command
(ADR-007) whose stdout image id the container runs. The Caddyfile carries no
secrets — the CF token is read from the container env ({env.CF_API_TOKEN}) — and
is rendered + bind-mounted from the host. Routes forge -> Forgejo:3000 and
s3 -> RustFS:9000; Vault is intentionally not proxied publicly (CONTRACT_003
"restricted").

Live on cx33 Helsinki: certs obtained for forge + s3; https://forge.olsitec.net
= 502 (Forgejo lands in T08) and https://s3.olsitec.net = 403 (RustFS), both over
valid Let's Encrypt certs (DNS-01). Acceptance T07 met.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Niemann 2026-06-30 21:54:12 +02:00
parent fa242e4e76
commit 6a7c28b54c
4 changed files with 168 additions and 4 deletions

View file

@ -56,7 +56,10 @@
# CONTAINER IMAGES (CONTRACT_003 §3.2 — every container the egg runs) # CONTAINER IMAGES (CONTRACT_003 §3.2 — every container the egg runs)
# Format: IMAGE_<NAME>=<repo>:<tag>@sha256:<digest|PIN_DIGEST> # Format: IMAGE_<NAME>=<repo>:<tag>@sha256:<digest|PIN_DIGEST>
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
IMAGE_CADDY=caddy:2.10@sha256:PIN_DIGEST # Caddy: the egg runs a CUSTOM build with the Cloudflare DNS-01 plugin (standard
# caddy:2 lacks it) — recipe + pinned base digests + module version live in
# containers/caddy-cloudflare/Dockerfile. This is the pinned FINAL base it derives.
IMAGE_CADDY=caddy:2.10@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb
IMAGE_FORGEJO=codeberg.org/forgejo/forgejo:11@sha256:PIN_DIGEST IMAGE_FORGEJO=codeberg.org/forgejo/forgejo:11@sha256:PIN_DIGEST
IMAGE_POSTGRES=postgres:17@sha256:5c855ad7b85e68e48a62f34662853f38b57c1c1d80f3a927ab58034fd6d31c5e IMAGE_POSTGRES=postgres:17@sha256:5c855ad7b85e68e48a62f34662853f38b57c1c1d80f3a927ab58034fd6d31c5e
IMAGE_VAULT=hashicorp/vault:1.18@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166 IMAGE_VAULT=hashicorp/vault:1.18@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166

View file

@ -0,0 +1,144 @@
// components/proxy.ts (T07)
//
// foundation-caddy — the ONLY public ingress (CONTRACT_003 §3.2): 80/443 published,
// automatic TLS via Let's Encrypt DNS-01 over Cloudflare (PLAN-002 §9.4). Standard
// caddy:2 lacks the DNS plugin, so the egg runs a custom xcaddy build
// (containers/caddy-cloudflare/Dockerfile) compiled on the VM (ADR-007); its image
// id is what the container runs. The Caddyfile (no secrets — the CF token is read
// from the container env) is rendered here and bind-mounted from the host.
//
// Vault is deliberately NOT proxied publicly (CONTRACT_003 "restricted"): it's
// reached internally over foundation-net / docker-exec. forge -> Forgejo (502 until
// T08), s3 -> RustFS.
import * as fs from "fs";
import * as path from "path";
import * as pulumi from "@pulumi/pulumi";
import * as docker from "@pulumi/docker";
import * as command from "@pulumi/command";
import { DeployCtx } from "../lib/context";
import { vmConnection } from "../lib/remote";
const DOCKERFILE = fs.readFileSync(
path.resolve(
__dirname,
"..",
"..",
"containers",
"caddy-cloudflare",
"Dockerfile",
),
"utf8",
);
// Dockerfile arrives on stdin; build logs -> stderr; the built image id -> stdout
// (so it becomes the container's image, recreated only when the Dockerfile changes).
const BUILD = `set -eu
docker build --provenance=false --sbom=false -t foundation-caddy:cf - >&2
docker image inspect foundation-caddy:cf --format '{{.Id}}'`;
const HOST_CADDYFILE = "/srv/foundation/caddy/Caddyfile";
const WRITE_CADDYFILE = `set -eu
mkdir -p /srv/foundation/caddy
cat > ${HOST_CADDYFILE}`;
export interface ProxyOutputs {
container: docker.Container;
imageId: pulumi.Output<string>;
}
function renderCaddyfile(ctx: DeployCtx): string {
const { cfg } = ctx;
const site = (host: string, upstream: string) =>
`${host} {
tls {
dns cloudflare {env.CF_API_TOKEN}
resolvers 1.1.1.1
}
reverse_proxy ${upstream}
}`;
return `{
email ${cfg.tls.acmeEmail}
}
${site(cfg.hosts.forge, "foundation-forgejo:3000")}
${site(cfg.hosts.s3, "foundation-rustfs:9000")}
`;
}
export function deployProxy(ctx: DeployCtx): ProxyOutputs {
const { provider, network } = ctx;
const conn = vmConnection(ctx);
// 1) Build the custom Caddy+Cloudflare image on the VM.
const build = new command.remote.Command("foundation-caddy-build", {
connection: conn,
create: BUILD,
update: BUILD,
stdin: DOCKERFILE,
addPreviousOutputInEnv: false,
triggers: [DOCKERFILE],
});
const imageId = build.stdout.apply((s) => s.trim());
// 2) Render + write the Caddyfile to the host (bind-mounted into the container).
const caddyfile = renderCaddyfile(ctx);
const writeCaddyfile = new command.remote.Command("foundation-caddy-config", {
connection: conn,
create: WRITE_CADDYFILE,
update: WRITE_CADDYFILE,
stdin: caddyfile,
addPreviousOutputInEnv: false,
triggers: [caddyfile],
});
// 3) Volumes: ACME certs/account (data) + autosave config (CONTRACT_003 §3.4).
const dataVol = new docker.Volume(
"foundation-caddy-data",
{ name: "foundation-caddy-data" },
{ provider, retainOnDelete: true }, // ACME account/certs — avoid re-issue churn
);
const configVol = new docker.Volume(
"foundation-caddy-config",
{ name: "foundation-caddy-config" },
{ provider },
);
const cfToken = new pulumi.Config("foundation").requireSecret(
"cloudflareApiToken",
);
const container = new docker.Container(
"foundation-caddy",
{
name: "foundation-caddy",
image: imageId,
hostname: "foundation-caddy",
restart: "unless-stopped",
envs: [pulumi.interpolate`CF_API_TOKEN=${cfToken}`],
ports: [
{ internal: 80, external: 80 },
{ internal: 443, external: 443 },
],
volumes: [
{ volumeName: dataVol.name, containerPath: "/data" },
{ volumeName: configVol.name, containerPath: "/config" },
{
hostPath: HOST_CADDYFILE,
containerPath: "/etc/caddy/Caddyfile",
readOnly: true,
},
],
networksAdvanced: [{ name: network.name, aliases: ["foundation-caddy"] }],
logDriver: "json-file",
logOpts: { "max-size": "10m", "max-file": "3" },
},
{
provider,
dependsOn: [network, build, writeCaddyfile],
deleteBeforeReplace: true,
},
);
return { container, imageId };
}

View file

@ -16,6 +16,7 @@ import {
import { deployPostgres } from "./components/postgres"; import { deployPostgres } from "./components/postgres";
import { deployRustfs } from "./components/rustfs"; import { deployRustfs } from "./components/rustfs";
import { deployVault } from "./components/vault"; import { deployVault } from "./components/vault";
import { deployProxy } from "./components/proxy";
const cfg = loadConfig(); const cfg = loadConfig();
@ -44,9 +45,9 @@ const vault = deployVault(ctx);
const vaultCreds = writeCredentialsToVault(ctx, credentials, vault); const vaultCreds = writeCredentialsToVault(ctx, credentials, vault);
// ============================================================================= // =============================================================================
// PHASE 6 — FORGE (depends on: credentials, GATE A) // PHASE 6 — FORGE (depends on: credentials, GATE A)
// T07 caddy · T08 forgejo · T10 runner // T07 caddy · T08 forgejo · T10 runner
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// const proxy = deployProxy(ctx); const proxy = deployProxy(ctx);
// const forgejo = deployForgejo(ctx, { postgres, rustfs, credentials, proxy }); // const forgejo = deployForgejo(ctx, { postgres, rustfs, credentials, proxy });
// --- GATE B: Forgejo healthy → handover (T11) + runner registration (T10). // --- GATE B: Forgejo healthy → handover (T11) + runner registration (T10).
// const runner = deployRunner(ctx, { forgejo, credentials }); // const runner = deployRunner(ctx, { forgejo, credentials });
@ -56,7 +57,8 @@ const vaultCreds = writeCredentialsToVault(ctx, credentials, vault);
// vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet. // vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet.
void vaultCreds; void vaultCreds;
export const phase = "T06-credentials"; // data-plane + creds-in-Vault export const phase = "T07-caddy"; // data-plane + creds + public ingress (Caddy)
export const caddyImageId = proxy.imageId;
export const networkName = network.name; export const networkName = network.name;
export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`; export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`;
export const postgresEndpoint = postgres.endpoint; export const postgresEndpoint = postgres.endpoint;

View file

@ -0,0 +1,15 @@
# Custom Caddy image with the Cloudflare DNS-01 plugin (standard caddy:2 lacks it,
# and DNS-01 is how the egg gets TLS before/without HTTP reachability — PLAN-002
# §9.4). Built on the VM by components/proxy.ts (ADR-007); the resulting image id
# is what the foundation-caddy container runs.
#
# Determinism (D5): base images pinned by digest, DNS module pinned by version.
# Bump = edit this file → proxy.ts's build command rebuilds (Dockerfile is a trigger).
# builder caddy:2.10-builder sha256:01668408cc26e2e00c9d067c30cb43b2ba14ad1f2808beda55503cb2a31f59dc
# final caddy:2.10 sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb
# module github.com/caddy-dns/cloudflare v0.2.4 (libdns/cloudflare v0.2.2)
FROM caddy@sha256:01668408cc26e2e00c9d067c30cb43b2ba14ad1f2808beda55503cb2a31f59dc AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare@v0.2.4
FROM caddy@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb
COPY --from=builder /usr/bin/caddy /usr/bin/caddy