Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/vps-page-factory-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: VPS page factory release

on:
push:
branches: [main]
paths:
- "index.html"
- "robots.txt"
- "sitemap.xml"
- "llms.txt"
- "schema.json"
- "aure/**"
- "coreweaver-labs/**"
- "field-systems/**"
- "ops/vps/**"
- ".github/workflows/vps-page-factory-release.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
release:
# Keep automatic deployment dormant until a named owner configures and approves
# an existing VPS target. The workflow does not create a server or edit DNS.
if: vars.VPS_RELEASES_ENABLED == 'true'
runs-on: ubuntu-latest
environment: vps-production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Validate static release artifact
run: node ops/vps/scripts/preflight.mjs
- name: Configure restricted deployment key and pinned server identity
env:
ARM_VPS_DEPLOY_KEY: ${{ secrets.ARM_VPS_DEPLOY_KEY }}
ARM_VPS_KNOWN_HOSTS: ${{ secrets.ARM_VPS_KNOWN_HOSTS }}
run: |
test -n "$ARM_VPS_DEPLOY_KEY"
test -n "$ARM_VPS_KNOWN_HOSTS"
install -d -m 700 ~/.ssh
printf '%s' "$ARM_VPS_DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$ARM_VPS_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Create immutable release
env:
CONFIRM_VPS_RELEASE: "yes"
VPS_HOST: ${{ vars.VPS_HOST }}
VPS_DEPLOY_USER: ${{ vars.VPS_DEPLOY_USER }}
VPS_PORT: ${{ vars.VPS_PORT }}
VPS_ROOT: ${{ vars.VPS_ROOT }}
RELEASE_ID: ${{ github.sha }}
run: bash ops/vps/scripts/release.sh
45 changes: 45 additions & 0 deletions ops/vps/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# VPS Page Factory

This directory makes the static ARM repository releasable to an already-approved VPS without a dashboard login for each page change. It deliberately contains no provider credential, hostname, IP address, or live DNS record.

The first server and public hostname remain an explicit infrastructure decision. After the server, deployment user, repository branch, DNS route, TLS issuance, rollback root, and GitHub deployment secrets are approved, ordinary source releases run the same preflight and immutable-release process.

## Required first-host baseline

| Area | Required before the first cutover |
| --- | --- |
| Server | Supported Linux host, non-root deployment user, SSH public-key access, a host key verified out-of-band and stored as `ARM_VPS_KNOWN_HOSTS`, provider snapshot, and owner for patching/incident response. |
| Network | Provider firewall and host firewall allow public TCP 80/443. Restrict SSH to known administrative networks; do not open database or administration ports publicly. |
| TLS | Install Caddy, associate the final hostname and page-specific document root in `Caddyfile`, check the domain’s CAA policy, and verify certificate issuance before canonical traffic changes. |
| Storage | Create `${VPS_ROOT}/releases`, `${VPS_ROOT}/shared`, and the `current` symlink owned by the deploy user. Keep a retained release set plus a provider-level snapshot. |
| Observability | Keep Caddy access/error logs, configure an anonymous HTTPS health check, and name the person who receives a failed-check alert. |
| DNS | Preserve the current origin, record the exact existing DNS values, and obtain explicit approval of the proposed record diff and rollback step. |

## Routine release

The release script validates the static content, uploads a uniquely named artifact, switches the `current` symlink only after extraction succeeds, and retains the newest five releases. It exits without network action unless `CONFIRM_VPS_RELEASE=yes` is set by an approved deployment workflow.

```bash
node ops/vps/scripts/preflight.mjs
CONFIRM_VPS_RELEASE=yes VPS_HOST=... VPS_DEPLOY_USER=... \
VPS_ROOT=/var/www/arm-page-factory bash ops/vps/scripts/release.sh
```

Set the deployment workflow’s variables, deploy key, and verified `ARM_VPS_KNOWN_HOSTS` secret only after the initial server ownership and hostname decisions are confirmed. `ARM_VPS_KNOWN_HOSTS` must be captured from a verified server/provider record, not generated during the deployment run. Do not set `VPS_RELEASES_ENABLED=true` until the first manually verified release has passed.

## Rollback

List releases on the server, point `current` to the prior verified release, reload Caddy only if its configuration changed, then run the anonymous HTTPS and mobile checks. For a failed DNS cutover, restore the recorded prior DNS target rather than changing unrelated nameserver, MX, or verification records.

## Production acceptance

Read [`production-acceptance.md`](production-acceptance.md) before moving any hostname from preview to canonical production. A production release needs an approved release manifest, an anonymous HTTPS check, a desktop and mobile render check, a working health-check timer, a verified retained release, and a named rollback owner. Passing source preflight alone is not production readiness.

## Resources

- `scripts/preflight.mjs` validates the repository artifact before upload.
- `scripts/release.sh` creates a guarded immutable release and switches `current`.
- `templates/Caddyfile` serves the static site with compression and explicit cache behavior.
- `templates/arm-page-factory.service` is an optional server-side one-shot release service.
- `cutover-manifest.example.yml` captures the exact first-host approval and rollback record.
- `hostinger-baseline.md` maps the kit to a Hostinger VPS without embedding a server ID, IP, credential, or public hostname.
46 changes: 46 additions & 0 deletions ops/vps/cutover-manifest.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# This file is a non-executable approval record. Do not replace placeholders or
# apply infrastructure changes until the owner approves the exact values.
target:
provider: __APPROVED_VPS_PROVIDER__
server_id: __APPROVED_SERVER_ID__
ipv4: __APPROVED_IPV4__
hostname: __APPROVED_PUBLIC_HOSTNAME__
canonical: false
access:
server_owner: __NAME__
deployment_owner: __NAME__
incident_owner: __NAME__
ssh_admin_source_ranges: []
release:
repository: CoreweaverLabs/autonomousresourcemanagement
source_branch: main
deployment_user: armdeploy
vps_root: /var/www/arm-page-factory
site_root: /var/www/arm-page-factory/current/field-systems
retained_releases: 5
security:
public_ports: [80, 443]
tls_terminator: Caddy
ssh_host_key_verified: false
provider_snapshot_verified: false
host_firewall_verified: false
certificate_verified: false
observability:
healthcheck_url: https://__APPROVED_PUBLIC_HOSTNAME__/
alert_owner: __NAME__
access_log_path: /var/log/caddy/arm-page-factory-access.log
dns_cutover:
previous_records: []
proposed_records: []
caa_records_checked: false
explicit_confirmation_received: false
rollback:
previous_origin: __CURRENT_PUBLIC_ORIGIN__
previous_dns_records: []
prior_release_id: __RELEASE_ID__
validation_steps:
- anonymous HTTPS response
- expected canonical redirect
- certificate chain
- homepage mobile check
- sitemap and robots response
82 changes: 82 additions & 0 deletions ops/vps/hostinger-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Hostinger VPS Baseline for the ARM Page Factory

## Scope and decision boundary

This baseline applies the provider-neutral page-factory kit to a **Hostinger VPS that already exists**. The connected account currently has no virtual machine, so the server identifier, IPv4 address, and public hostname remain intentionally blank. Do not execute any step that creates a server, changes DNS, changes nameservers, changes a firewall, attaches a public key, or issues a certificate without an approved cutover manifest.

Hostinger exposes virtual-machine, backup, snapshot, public-key, firewall, and post-install-script functions in its VPS API; this guide uses those as the responsibility map, not as an instruction to invoke them automatically.[1]

## Required values after provisioning approval

| Value | Example format | Owner | Where it belongs |
| --- | --- | --- | --- |
| VPS identifier | Provider-generated resource ID | Infrastructure owner | Approved cutover manifest only. |
| IPv4 address | `203.0.113.10` | Infrastructure owner | Approved cutover manifest and GitHub environment variable. |
| Public hostname | `field-systems.example.com` | Domain owner | Caddy configuration, health-check env, and cutover manifest. |
| Deployment user | `armdeploy` | Release owner | VPS and GitHub variable. |
| Deployment SSH public key | Ed25519 public key | Release owner | VPS public-key attachment. |
| GitHub deploy private key | Ed25519 private key | Release owner | `ARM_VPS_DEPLOY_KEY` GitHub environment secret only. |
| SSH administrative ranges | Named office/VPN/CIDR sources | Infrastructure owner | Provider and host firewall record. |
| Monitoring alert recipient | Named person or approved alert target | Operations owner | Monitoring record; never committed to source. |

## First-host implementation sequence

| Order | Hostinger-side action | Acceptance evidence | Does this change public traffic? |
| --- | --- | --- | --- |
| 1 | Provision a supported Linux VPS and record its immutable server ID, location, and IPv4 in the approved manifest. | Provider resource record and a recovery/snapshot decision. | No. |
| 2 | Add the release owner’s SSH public key and disable password-based routine access where the chosen image supports it. | Successful key-based, non-root SSH login. | No. |
| 3 | Create `armdeploy`, grant ownership of `/var/www/arm-page-factory`, and keep the deployment process non-root. | `id armdeploy` and ownership record. | No. |
| 4 | Configure the Hostinger firewall to allow TCP 80 and 443 publicly and SSH only from approved administrative ranges; reinforce the same policy with the host firewall. | Provider-rule and host-rule records. | No, until DNS points to the host. |
| 5 | Install Caddy and deploy the reviewed `Caddyfile`; create Caddy’s log directory with appropriate service ownership. | `caddy validate` and service status. | No, until DNS points to the host. |
| 6 | Create `/etc/arm-page-factory/healthcheck.env` containing only the approved public URL and intended route paths. Install the reviewed health-check service and timer. | `systemctl status` for service and timer. | No. |
| 7 | Configure GitHub environment `vps-production` with approval protection, deployment variables, and the restricted release key secret. Keep `VPS_RELEASES_ENABLED` false while the host is still unverified. | Environment configuration review. | No. |
| 8 | Perform one manual immutable release to a noncanonical host. Confirm retained release, logs, anonymous HTTPS, mobile render, and rollback. | Acceptance checklist and release ID. | Only on the noncanonical host. |
| 9 | Enable routine branch deployment after a successful release and rollback rehearsal. | Approved production acceptance record. | No new DNS change. |

## Firewall baseline

Hostinger’s managed firewall operates before server traffic reaches the instance and uses explicit rules. An empty active rule set blocks traffic, so record the intended allow-list before attaching it to the VPS.[2]

| Layer | Rule | Source | Reason |
| --- | --- | --- | --- |
| Hostinger firewall | Allow TCP 80 | Anywhere | HTTP-to-HTTPS handling and certificate validation. |
| Hostinger firewall | Allow TCP 443 | Anywhere | Public HTTPS delivery. |
| Hostinger firewall | Allow TCP 22 | Approved administrative CIDRs only | Break-glass and administration access. |
| Hostinger firewall | Reject or drop all other inbound traffic | Anywhere | Prevent unneeded public services. |
| Host firewall | Match the provider allow-list | Same as above | Defense in depth and finer host control. |

Do not expose a database, Docker daemon, Caddy admin interface, monitoring dashboard, deployment webhook receiver, or SSH to the open internet without a separately approved design.

## Caddy and health-check installation record

The server’s actual Caddy configuration uses the reviewed repository template, replacing only the public hostname and the approved release root. Caddy should be validated before reload. The health-check environment should contain a single `PUBLIC_URL` for the noncanonical preview first, then the approved production hostname after cutover. The timer’s five-minute interval is an availability check, not a publishing trigger; its service only performs an anonymous HTTPS response check and logs a failure.

Store deploy-time values outside the repository:

| Location | Contains | Must not contain |
| --- | --- | --- |
| GitHub `vps-production` environment variables | Host, user, port, release root, `VPS_RELEASES_ENABLED`. | Private keys, API tokens, hostname changes not recorded in the manifest. |
| GitHub `vps-production` environment secrets | `ARM_VPS_DEPLOY_KEY`. | DNS/provider credentials unless a separately approved automation design needs them. |
| `/etc/arm-page-factory/healthcheck.env` | `PUBLIC_URL`, health-check paths, timeout. | Provider API key, GitHub private key, or public-site content. |
| `/etc/caddy/Caddyfile` | Public hostname, site root, headers, log path. | SSH keys, API credentials, or private operational contacts. |

## Backup and rollback

Take a Hostinger snapshot or confirm the provider backup posture before the first host configuration. Preserve the five immutable application releases defined by the release kit. Provider recovery is for a server-level failure; the `current` symlink is for a bad static release; the DNS rollback record is for a bad routing change. Each has a different owner and a different recovery path.

## First cutover hold points

The final cutover manifest remains blocked until all fields below are named:

1. Provider and VPS identifier.
2. Public hostname and the exact pre-existing DNS records.
3. The authoritative DNS operator and the permitted record change.
4. CAA and certificate readiness.
5. Operations, release, and rollback owners.
6. The preview release ID and its acceptance record.

## References

[1] [Hostinger API Reference — VPS operations](https://developers.hostinger.com/)

[2] [Hostinger, “How to use a managed VPS firewall.”](https://www.hostinger.com/support/8172641-how-to-use-a-managed-vps-firewall-at-hostinger/)
26 changes: 26 additions & 0 deletions ops/vps/production-acceptance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# VPS Page Factory — Production Acceptance

## Release candidate checklist

| Area | Acceptance check | Evidence to record | Owner |
| --- | --- | --- | --- |
| Content and route | Page carries a specific audience decision, a claim map, source links, metadata, canonical route, and an appropriate ARM/context link. | Release packet and committed source revision. | Content owner. |
| Static artifact | `node ops/vps/scripts/preflight.mjs` passes and the deployment archive contains only the intended public files. | CI log and release identifier. | Release owner. |
| Access and TLS | Anonymous HTTPS returns a valid success response for root and each intended route; the certificate name, redirect policy, and CAA conditions are checked. | Timestamped response and certificate check. | Infrastructure owner. |
| Security | Provider and host firewalls expose only approved public ports; SSH access is key-based and restricted; Caddy headers and served-path exclusions are in place. | Firewall rule record and configuration revision. | Infrastructure owner. |
| Visual and mobile | The page is inspected at desktop and a 375px mobile viewport; no primary control, text block, or route is hidden or horizontally clipped. | Screenshots and issue disposition. | Release owner. |
| Observability | The HTTPS health-check service and timer are active, logs are retained, and a named person receives failed-check notification. | Timer status and alert test record. | Operations owner. |
| Rollback | A prior release is retained; the current symlink target and prior DNS/origin record are documented; the rollback command is rehearsed in preview. | Release list, manifest, and rehearsal record. | Release and infrastructure owners. |

## Cutover order

1. Verify the candidate on the VPS using a noncanonical preview hostname.
2. Capture the current DNS and origin state in the approved cutover manifest.
3. Confirm the exact record change, TTL, canonical redirect behavior, certificate readiness, and rollback owner.
4. Apply the approved DNS change once; do not alter nameservers or unrelated records.
5. Check anonymous HTTPS, route responses, certificate chain, desktop/mobile rendering, health-check timer, logs, and canonical behavior.
6. If any required check fails, restore the recorded prior DNS/origin before debugging further.

## Routine release condition

After a successful first release and rollback rehearsal, routine releases can be automated only for an approved branch and hostname. The workflow remains prohibited from purchasing infrastructure, changing DNS, changing a canonical hostname, modifying the firewall, publishing new claims outside the reviewed source, or initiating external communication.
21 changes: 21 additions & 0 deletions ops/vps/scripts/healthcheck.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail

: "${PUBLIC_URL:?PUBLIC_URL is required, e.g. https://field-systems.example.com}"
HEALTHCHECK_PATHS="${HEALTHCHECK_PATHS:-/}"
MAX_TIME_SECONDS="${MAX_TIME_SECONDS:-20}"

if [[ ! "$PUBLIC_URL" =~ ^https:// ]]; then
echo "PUBLIC_URL must use HTTPS." >&2
exit 64
fi
if [[ ! "$MAX_TIME_SECONDS" =~ ^[0-9]+$ ]]; then
echo "MAX_TIME_SECONDS must be numeric." >&2
exit 64
fi

base="${PUBLIC_URL%/}"
for path in $HEALTHCHECK_PATHS; do
response="$(curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 --max-time "$MAX_TIME_SECONDS" --write-out ' status=%{http_code} total=%{time_total}s' --output /dev/null "${base}${path}")"
echo "${base}${path}${response}"
done
36 changes: 36 additions & 0 deletions ops/vps/scripts/preflight.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";

const root = resolve(process.cwd());
const requiredFiles = ["index.html", "robots.txt", "sitemap.xml", "llms.txt", "schema.json"];
const errors = [];

for (const file of requiredFiles) {
const path = resolve(root, file);
if (!existsSync(path) || !statSync(path).isFile()) {
errors.push(`Missing required static artifact: ${file}`);
}
}

if (errors.length === 0) {
const index = readFileSync(resolve(root, "index.html"), "utf8");
const robots = readFileSync(resolve(root, "robots.txt"), "utf8");
const sitemap = readFileSync(resolve(root, "sitemap.xml"), "utf8");
const schema = readFileSync(resolve(root, "schema.json"), "utf8");

if (!index.includes('application/ld+json')) errors.push("index.html has no inline JSON-LD block");
if (!robots.match(/^Sitemap:\s*https?:\/\//im)) errors.push("robots.txt has no absolute Sitemap directive");
if (!sitemap.includes("<urlset")) errors.push("sitemap.xml does not contain a URL set");
if (!schema.includes('"@context"')) errors.push("schema.json does not contain JSON-LD context");
if (index.includes("__ARM_HOSTNAME__") || index.includes("REPLACE_ME")) {
errors.push("index.html still contains a release placeholder");
}
}

if (errors.length > 0) {
console.error("VPS page-factory preflight failed:");
for (const error of errors) console.error(`- ${error}`);
process.exit(1);
}

console.log(`VPS page-factory preflight passed for ${root}`);
Loading
Loading