ops: phasefinal.com moved to Cloudflare Pages; pfi-ana-webhost destroyed and removed from inventory, DNS and the FortiGate
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
# phasefinal-web
|
||||
|
||||
The Phase Final, Inc. corporate site — nine static pages served by nginx on
|
||||
**ana-docker**, fronted by Traefik at `www.phasefinal.com`.
|
||||
The Phase Final, Inc. corporate site — nine static pages on **Cloudflare Pages**
|
||||
(project `phasefinal`, account `vh@phasefinal`, `phasefinal.pages.dev`), at
|
||||
`www.phasefinal.com` and `phasefinal.com`. Moved off ana-docker 2026-10-04 (Prime);
|
||||
the nginx container there was retired (`/opt/docker/{compose,conf}/phasefinal-web.retired-20261004`).
|
||||
`conf/site/` is the deployed tree, including `_headers` (the response headers nginx used
|
||||
to set) and `404.html` (without it Pages serves the home page for every unknown path).
|
||||
|
||||
Built from the design brief at `docs/design/` (boothed 2026-08-29); the markup
|
||||
and CSS came from a Claude Design session and are checked in verbatim under
|
||||
@@ -21,35 +25,31 @@ requests rule. Both must be undone on import:
|
||||
Without these the page silently falls back to system fonts and looks wrong
|
||||
rather than broken, which is the failure mode you won't notice.
|
||||
|
||||
## Routing — and the healthcheck trap that looks like a routing bug
|
||||
|
||||
Routers come from the compose labels: `phasefinal-web` serves
|
||||
`www.phasefinal.com`; `phasefinal-apex` 301s `phasefinal.com` to it.
|
||||
|
||||
⚠ **Traefik silently skips containers Docker reports as unhealthy.** There is no
|
||||
error, no log line, and no router — it looks exactly like a broken provider. The
|
||||
first deploy of this stack hit that: the healthcheck used
|
||||
`http://localhost/`, which resolves to `::1` in `nginx:alpine`, and nginx listens
|
||||
on IPv4 only, so the check got "Connection refused" forever and the container
|
||||
never left `unhealthy`. **Use `127.0.0.1`, never `localhost`, in a healthcheck
|
||||
for an IPv4-only listener** — and if a correctly-labelled container never appears
|
||||
in `/api/http/routers`, check `docker inspect --format '{{.State.Health.Status}}'`
|
||||
*before* suspecting Traefik.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
scripts/deploy-stack.sh ana-docker phasefinal-web
|
||||
ssh infra-ops@10.250.50.70 'cd /opt/docker/compose/phasefinal-web && docker compose up -d'
|
||||
stacks/phasefinal-web/deploy-pages.sh # conf/site → Pages production (branch main)
|
||||
```
|
||||
|
||||
## DNS
|
||||
Needs a vaulted Cloudflare token with Account → Cloudflare Pages: Edit (see the script).
|
||||
⚠ The 2026-10-04 migration used jackdaw's token as a one-off; a dedicated
|
||||
`nh3-dev/.config/cloudflare/infra-ops-pages-token` is pending Prime. Check the result on
|
||||
the per-deploy preview URL wrangler prints before trusting the custom domains.
|
||||
|
||||
`www.phasefinal.com` → A `38.120.12.44` (the Anaheim public IP, forwarded to
|
||||
ana-docker's Traefik). The apex `phasefinal.com` has **no** record — the site is
|
||||
www-only by operator decision. Cloudflare-proxied is the intended end state, for
|
||||
edge caching and the automatic email-address obfuscation the contact block
|
||||
relies on.
|
||||
## DNS and redirects
|
||||
|
||||
`www.phasefinal.com` and `phasefinal.com` → CNAME `phasefinal.pages.dev`, Cloudflare-proxied
|
||||
(both are Pages custom domains). Cloudflare-proxied is also what provides the email-address
|
||||
obfuscation the contact block relies on. ⚠ **The apex no longer 301s to www**: Traefik used
|
||||
to do that, Pages `_redirects` cannot redirect between hostnames, and no vaulted token may
|
||||
create a zone Redirect Rule. Both hostnames serve the site until Prime mints a token with
|
||||
Single Redirect: Edit; then add the rule `phasefinal.com/* → https://www.phasefinal.com/${1}` (301).
|
||||
|
||||
## Lesson from the ana-docker era (2026-08-29)
|
||||
|
||||
Traefik silently skips containers Docker reports as unhealthy: no error, no router. The first
|
||||
deploy's healthcheck used `http://localhost/`, which is `::1` in `nginx:alpine` while nginx
|
||||
listened on IPv4 only. Use `127.0.0.1` in healthchecks for IPv4-only listeners.
|
||||
|
||||
## Content constraints
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ reviewable and reproducible rather than living only in someone's dashboard.
|
||||
|
||||
| name | type | value | proxied |
|
||||
|---|---|---|---|
|
||||
| `www.phasefinal.com` | A | `38.120.12.44` | yes |
|
||||
| `phasefinal.com` | A | `38.120.12.44` | yes |
|
||||
| `www.phasefinal.com` | CNAME | `phasefinal.pages.dev` | yes |
|
||||
| `phasefinal.com` | CNAME | `phasefinal.pages.dev` | yes |
|
||||
|
||||
`38.120.12.44` is the Anaheim public IP, forwarded to ana-docker's Traefik.
|
||||
The apex 301s to `www` via a Traefik middleware, not a Cloudflare rule.
|
||||
Since 2026-10-04 both are Cloudflare Pages custom domains (project `phasefinal`). They used to be
|
||||
A `38.120.12.44` → ana-docker's Traefik, which also 301'd the apex to www; that redirect is gone
|
||||
until a Redirect Rule can be created (see `../README.md`). The cache rule below still applies and
|
||||
is harmless with Pages as the origin (it respects origin headers).
|
||||
|
||||
## Cache ruleset
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
container_name: phasefinal-web
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /opt/docker/conf/phasefinal-web/site:/usr/share/nginx/html:ro
|
||||
- /opt/docker/conf/phasefinal-web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
networks:
|
||||
- tnet
|
||||
healthcheck:
|
||||
# 127.0.0.1, NOT localhost: in this image `localhost` resolves to ::1 and
|
||||
# nginx listens on IPv4 only, so `localhost` gives "Connection refused"
|
||||
# forever. Traefik SKIPS unhealthy containers, so a wrong healthcheck here
|
||||
# silently means "no route" rather than "unhealthy service".
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.phasefinal-web.rule=Host(`www.phasefinal.com`)
|
||||
- traefik.http.routers.phasefinal-web.tls=true
|
||||
- traefik.http.routers.phasefinal-web.tls.certresolver=anaprod
|
||||
- traefik.http.services.phasefinal-web.loadbalancer.server.port=80
|
||||
# apex -> www, 301
|
||||
- traefik.http.routers.phasefinal-apex.rule=Host(`phasefinal.com`)
|
||||
- traefik.http.routers.phasefinal-apex.tls=true
|
||||
- traefik.http.routers.phasefinal-apex.tls.certresolver=anaprod
|
||||
- traefik.http.routers.phasefinal-apex.service=phasefinal-web
|
||||
- traefik.http.routers.phasefinal-apex.middlewares=phasefinal-to-www
|
||||
- traefik.http.middlewares.phasefinal-to-www.redirectregex.regex=^https?://phasefinal\.com/(.*)
|
||||
- traefik.http.middlewares.phasefinal-to-www.redirectregex.replacement=https://www.phasefinal.com/$${1}
|
||||
- traefik.http.middlewares.phasefinal-to-www.redirectregex.permanent=true
|
||||
|
||||
networks:
|
||||
tnet:
|
||||
name: traefik-net
|
||||
external: true
|
||||
@@ -1,18 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Static site; long cache on immutable assets, short on the document so
|
||||
# a dictated edit goes live on the next deploy without a purge.
|
||||
location = / { add_header Cache-Control "public, max-age=300"; try_files /index.html =404; }
|
||||
location ~* \.(woff2|svg|png|ico)$ { add_header Cache-Control "public, max-age=31536000, immutable"; }
|
||||
location ~* \.css$ { add_header Cache-Control "public, max-age=3600"; }
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
|
||||
location / { try_files $uri $uri/ =404; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0f1318">
|
||||
<title>Page not found — Phase Final, Inc.</title>
|
||||
<meta name="description" content="Page not found.">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/style.css?v=2026-09-28">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="site-header">
|
||||
<div class="site-header-inner">
|
||||
<a class="brand" href="/"><img src="/assets/pfi-wordmark-duotone-dark.svg" alt="Phase Final, Inc." width="98" height="28"></a>
|
||||
<nav class="site-nav" aria-label="Site">
|
||||
<ul>
|
||||
<li><a href="/services/software/">Applications</a></li>
|
||||
<li><a href="/services/systems-engineering/">Systems</a></li>
|
||||
<li><a href="/services/hosting/">Hosting</a></li>
|
||||
<li><a href="/services/ai/">AI</a></li>
|
||||
<li><a href="/engagement/">Engagement</a></li>
|
||||
<li><a href="/track-record/">Track record</a></li>
|
||||
<li><a href="/contact/">Contact</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-copy">
|
||||
<h1 class="lede">Page not found.</h1>
|
||||
<p class="sublede">There is nothing at this address. <a href="/">Return to the home page</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>Phase Final, Inc.</span>
|
||||
<nav aria-label="Footer">
|
||||
<a href="/services/software/">Applications</a>
|
||||
<a href="/services/systems-engineering/">Systems</a>
|
||||
<a href="/services/hosting/">Hosting</a>
|
||||
<a href="/services/ai/">AI</a>
|
||||
<a href="/engagement/">Engagement</a>
|
||||
<a href="/track-record/">Track record</a>
|
||||
<a href="/contact/">Contact</a>
|
||||
<a href="/privacy/">Privacy</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
# Cloudflare Pages response headers (ported from the retired nginx.conf on ana-docker, 2026-10-04).
|
||||
/*
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Referrer-Policy: no-referrer
|
||||
! Access-Control-Allow-Origin
|
||||
|
||||
/fonts/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/favicon.svg
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/style.css
|
||||
Cache-Control: public, max-age=3600
|
||||
@@ -46,7 +46,7 @@
|
||||
<ul class="detail-list">
|
||||
<li><h3>What this site collects</h3><p>Nothing from your browser. Fonts and images are served from this site itself. No advertising or analytics scripts are loaded, so no cross-site profiling occurs on our side.</p></li>
|
||||
<li><h3>What email you send us</h3><p>Inquiries sent to inquiry@phasefinal.com are retained as ordinary business email for correspondence and engagement records. We do not add you to marketing lists; there are no marketing lists.</p></li>
|
||||
<li><h3>Hosting</h3><p>This site is served from infrastructure the firm operates, fronted by Cloudflare for TLS and caching. Server logs exist for abuse control and are not joined to anything else.</p></li>
|
||||
<li><h3>Hosting</h3><p>This site is static files hosted on Cloudflare Pages, which serves it over TLS. The firm keeps no server logs for it; Cloudflare processes requests under its own privacy policy.</p></li>
|
||||
<li><h3>Questions</h3><p>Write to <a href="mailto:inquiry@phasefinal.com">inquiry@phasefinal.com</a>.</p></li>
|
||||
</ul>
|
||||
<p class="related"><a href="/">Home →</a></p>
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy phasefinal.com to Cloudflare Pages (project `phasefinal`, account vh@phasefinal).
|
||||
# stacks/phasefinal-web/deploy-pages.sh
|
||||
# The site is the static tree in conf/site/ (with its _headers and 404.html); there is no build step.
|
||||
# Token: a Cloudflare API token with Account → Cloudflare Pages: Edit, read from the vault.
|
||||
# PAGES_TOKEN_KEY defaults to the dedicated infra-ops token (to be minted by Prime);
|
||||
# the 2026-10-04 migration used nh3-dev/development/jackdaw/cloudflare-api-token as a one-off.
|
||||
set -euo pipefail
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
key=${PAGES_TOKEN_KEY:-nh3-dev/.config/cloudflare/infra-ops-pages-token}
|
||||
CLOUDFLARE_API_TOKEN=$(secret get "$key") || { echo "no token at vault key $key (set PAGES_TOKEN_KEY)" >&2; exit 2; }
|
||||
export CLOUDFLARE_API_TOKEN
|
||||
CLOUDFLARE_ACCOUNT_ID=$(curl -s -m 15 -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" https://api.cloudflare.com/client/v4/accounts \
|
||||
| python3 -c 'import json,sys; r=json.load(sys.stdin)["result"]; print(next(a["id"] for a in r if a["name"]=="vh@phasefinal"))')
|
||||
export CLOUDFLARE_ACCOUNT_ID
|
||||
# run wrangler from a scratch dir so it never drops .wrangler/ state into the repo
|
||||
cd "$(mktemp -d)"
|
||||
npx -y wrangler@4 pages deploy "$here/conf/site" --project-name phasefinal --branch main --commit-dirty=true \
|
||||
--commit-message "deploy from eshpfi-management $(git -C "$here" rev-parse --short HEAD)"
|
||||
Reference in New Issue
Block a user