This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Download

Public static files and websites on XCP-ng and Yandex Cloud

Static hosting architecture

One VM per environment runs the shared Traefik service in front of native Nginx. Clients connect directly on ports 80/443; Nginx listens only on 127.0.0.1:8008. Public DNS selects Yandex and dc1 DNS selects XCP-ng. These are independent stores with no replication, failover, or backups.

Each VM has a 100 GiB Btrfs content disk mounted at /srv/download:

projects/<project>/releases/<version>/<uploaded files>
sites/alwaldend.com/releases/<version>/<extracted website>
sites/alwaldend.com/current -> releases/<version>
staging/
state/

download.alwaldend.com/projects/ returns JSON directory listings and serves file bytes, including website archives. alwaldend.com serves the selected extracted website. www.alwaldend.com redirects permanently to the apex, preserving the path and query. Staging and service state have no public route. Directory listings never use a release’s index.html as an index page.

The download account owns content and staging; Ansible does not configure its authorized_keys. Administrators use existing SSH access and sudo to run publication as this account. The host role installs rsync. Nginx reads content and cannot write it. Ansible never publishes or selects a release.

The release tool and component publishing targets are delivered in PR #113, independently against master. Their commands and transfer behavior belong to the release tool documentation. The website browser change also remains separate implementation work.

A daily download-deduplicate.timer runs duperemove over projects/ and sites/ on each VM. It shares identical extents while retaining independent files, paths, permissions, and copy-on-write behavior. Repeated website assets can share storage; separately compressed archives may have fewer identical extents. Savings are workload-dependent and stay within each environment.

A private hash database under state/deduplication/ makes subsequent runs incremental. Staging, ACME state, and the database are outside the scan roots. The job runs with one I/O and one CPU thread, idle I/O priority, a half-core CPU limit, and a 512 MiB memory limit. Systemd serializes runs; the timer makes up a missed run after downtime. The service requires the content mount.

The job never deletes or activates releases. Its result is available through systemctl status download-deduplicate.service and the service journal. btrfs filesystem du reports shared/exclusive allocation; ordinary file sizes and download responses remain their logical sizes. The declared filesystem setup does not force formatting over an existing filesystem.

Terraform provisioning, provider assignments, and DNS declarations are merged through PR #108. Host configuration connects by unique inventory FQDNs and needs no packaged DNS inputs. The shared AL configuration selects provisioning or host credentials by stage. DNS snapshots are regenerated only on manual request.

local, yandex, and dns are separate Terraform roots directly under this project. Their separate state keys are alwaldend.com/vault1/approles/src_infra_download/tf_backend/<stage>. Selecting one root cannot plan deletion of another root’s resources, and no root reads another root’s complete Terraform state.

  • local owns the XCP-ng VM and two disks. prevent_destroy blocks VM replacement because the pinned provider cannot retain and reattach its data disk separately. Replacement needs a future reviewed storage workflow.
  • yandex owns the cloud VM, network, firewall, reserved public address, retained content disk, and its Yandex DNS zone and host record. VM replacement reuses the content disk and reserved address.
  • dns owns only this component’s Cloudflare/RouterOS aliases and delegation. Each stage has its own Vault HTTP state key.
  • infra/dns retains apex, mail, and www ownership. Its public apex follows download.alwaldend.com; that stable service name follows the component’s Yandex host. Its local apex A record is the canonical VM address consumed by Terraform. The unique local host CNAME and download alias follow that local apex. Endpoint changes therefore have one maintained input per environment. Ansible connects by inventory FQDN, without parsing DNS source. Old Pages A/AAAA records are removed through staged owner cutovers; unrelated mail and staging records remain.
  • ansible mounts storage, configures SSH and service access, labels static content for SELinux, and deploys the shared Traefik and Nginx roles.

The local address 192.168.10.66 is unclaimed in checked-in DNS declarations; confirm its availability and the named XCP-ng template/resource-set inventory before deployment. The cloud image defaults to the immutable Fedora 43 image already used by infra/threexui; local provisioning uses Fedora 44.

The merged infra/vault/approles/src_infra_download root owns the component identity. Its applied identity and core memberships are documented by the Vault owner. infra/xcp_ng/tf owns the XO resource-set assignment; infra/yandex_cloud/org1/tf owns the folder and its standard folder-scoped admin service account. AL selects only the chosen stage’s provider credentials and state backend.

AL reads the existing Cloudflare/RouterOS credentials for DNS provisioning, and the component Yandex account from Vault. Traefik uses HTTP-01 in both environments: Let’s Encrypt on Yandex, and the existing Vault ACME service with role-scoped EAB on XCP-ng. The component’s Vault PKI role permits only the apex, download, and www names. Local clients must trust the repository CA; neither environment requires a client certificate or login. Traefik configuration and private ACME state stay on the system disk under /opt/traefik, independent of the content mount. DNS provider credentials are not installed on either host.

The shared host role installs the CA needed to reach Vault. Each inventory key is the unique host FQDN used for connection and SSH host-key checks. The shared SSH role includes inventory_hostname when signing keys; no host-key alias or address override is needed.

The following are operator entry points, not evidence of a deployed service. Confirm the local address, named XO inventory, template disk/interface layout, and cloud image before deployment. Each live operation requires its own approved scope.

  1. Complete the Vault prerequisite and ensure the existing XO OIDC service is configured through its owner. Obtain explicit authorization for the first-login bootstrap of src_infra_download: it creates the external XO identity/groups. Run the existing bootstrap target and record its result:

    XO_BOOTSTRAP_APPROLE=src_infra_download bazel_agent bazel run //infra/xcp_ng/cmd/xo_login:bootstrap
    
  2. After that login, review/apply the XO resource-set assignment through infra/xcp_ng/tf to reconcile the identity’s bindings in IaC. Confirm the AppRole is absent from approles_pending_oidc_login and its resource set has the intended subject before planning the local VM. Review/apply the Yandex folder assignment through infra/yandex_cloud/org1/tf as well.

  3. Review and apply //infra/download/local:tf.plan/tf.apply and //infra/download/yandex:tf.plan/tf.apply independently.

  4. Prepare the dc1 apex A record through infra/dns/tf while preserving public Pages records, following the local DNS procedure. Apply //infra/download/dns:tf.plan/tf.apply to establish cloud delegation and host/service aliases. Verify both inventory FQDNs before Ansible. The local website is in maintenance until host configuration and publication.

  5. Run //infra/download/ansible:ansible.local or :ansible.yandex. There is deliberately no unqualified command that configures both environments. Bootstrap host-key trust through the existing SSH procedure before the first connection; subsequent host certificates use the component role.

  6. Publish the site content and verify HTTP routing with address overrides. Follow the two-phase apex cutover through //infra/dns/tf:tf.plan/tf.apply. HTTP-01 requires public DNS/port 80 to reach Yandex and Vault’s DNS view/port 80 to reach XCP-ng before either issuer can validate the three names.

  7. Exercise Let’s Encrypt staging with an explicit directory override and separate ACME storage, then select production; test Vault issuance locally. This sequence has a certificate-bootstrap interval after DNS cutover. Verify both trust chains, renewal, www redirects, downloads, and reboot persistence. Keep the previous deployment available for a reviewed recovery.

Use bazel_agent bazel run <target> for these commands. Provider credentials and backend state are injected by AL; no host-installed Terraform or Ansible is required. See the acceptance matrix for isolated checks and the remaining live acceptance cases. Provisioning evidence retains the Terraform validation and review history.

1 - Download DNS

Cloudflare and RouterOS aliases for static hosting

Owns the component’s dnsconfig.json through the shared DNS module. Public download requests follow the delegated Yandex host. The local host CNAME follows the apex owner’s canonical local A record. The apex and www keep their existing owner; DNS cutover is a separately reviewed live operation.

This root selects only DNS credential injection and its own state backend. It does not authenticate with either VM provider or require either VM’s state.

Before configuring the local host by its inventory FQDN, prepare and commit a reviewed intermediate infra/dns/dnsconfig.json revision that installs the final download_dc1 A record in the dc1 view and removes the old dc1 Pages A/AAAA members. Keep the old public Pages A/AAAA records and all unrelated records unchanged; split prior dsp: ["all"] declarations by view as needed. Plan and apply that exact revision through infra/dns/tf, after separately authorizing this local DNS change. Review that only the intended dc1 apex records change and verify the local answer before continuing.

Apply this component’s aliases after that preparation, then confirm host1.dc1.download.alwaldend.com resolves to the address used by the VM. Ansible connects using that unique hostname. The local website is in a maintenance interval until host configuration and content publication finish; the public website continues using Pages during this local preparation.

The local VM consumes the same apex A value from source. The host CNAME and local download alias follow it, so renumbering has one maintained address. The public apex follows download.alwaldend.com, which in turn follows the component-owned Yandex host. Renaming that host therefore requires no copied endpoint edit in the apex owner.

The apex belongs to infra/dns/tf, separately from this component DNS root. Its old GitHub Pages A/AAAA records and the new public CNAME have different Terraform instance keys. Applying the final declaration directly can race CNAME creation against address-record deletion. Cloudflare rejects that coexistence; use two sequential, reviewed IaC revisions and saved plans.

Before either phase, complete the DNS owner’s adoption procedure, verify both hosts and content using address overrides, configure certificate issuers, and authorize the apex cutover explicitly. Record the prior source revision and recovery plan. Schedule a maintenance window: the public apex has an intentional address gap between the phases, and resolver caches can extend that interval.

  1. Prepare and commit a reviewed intermediate infra/dns/dnsconfig.json revision with the old public apex A/AAAA members removed from the global view and records.download_global absent. Keep local records, www, mail, TXT, and unrelated records at their prior values. Through //infra/dns/tf:tf.plan, save and review a plan that deletes only the old public apex A/AAAA instances and creates no apex CNAME. Apply that exact saved plan through //infra/dns/tf:tf.apply. Verify completion and the authoritative Cloudflare inventory shows those A/AAAA records are gone.
  2. Only after phase 1 succeeds, select the reviewed final declaration from this change, including records.download_global. Create a fresh saved plan through the same owner; review the CNAME creation and intended local apex changes while preserving mail/TXT/www and unrelated records. Apply that plan, then verify authoritative records and both DNS views, website responses, and redirects.

Keep all source/input files unchanged between each saved plan and its apply. If a phase fails, stop and inspect the actual record/state inventory before planning recovery. Restore the recorded prior declaration only through a reviewed owning-root plan; remove any conflicting CNAME first if returning to the old A/AAAA records. Never begin phase 2 based only on a planned deletion.

2 - Host configuration

Shared native Traefik and Nginx deployment

Build :ansible_bin to package the playbook and shared collection. The play uses alwaldend.main.download_host for storage, the publisher account, rsync, service setup, and deduplication. The role does not configure publisher authorized keys; administrators use their existing SSH access and run publication through sudo as download. Inventory, Vault injection, and routing templates belong to this deployment. Both inventories connect using their unique host FQDNs, with the same names for SSH host-key checks. Host DNS must resolve before configuration; no IP override or HostKeyAlias is configured. Run :ansible.local or :ansible.yandex only with explicit live authorization. Before configuring the host, Ansible requires the content path to resolve to a block device. The role creates the filesystem without forcing an overwrite and mounts it before enabling the services. The shared roles manage the service units. Traefik keeps its configuration and ACME data on the system disk at /opt/traefik and has no content-mount dependency. The role creates publication roots; publishers create individual project/site directories and select releases. It never uploads or selects releases.

nginx_config_template supplies the complete configuration to the reusable Nginx role. JSON listings allow credential-free cross-origin reads and normal HEAD/range downloads. Site requests follow the current link. Port 8008 is loopback-only and uses the Fedora SELinux HTTP port type. Public content has read-only HTTP labels; the SSH publisher needs readable file modes and must preserve those labels when activating staged content.

Yandex uses Let’s Encrypt HTTP-01 without EAB; XCP-ng uses the existing Vault HTTP-01 directory with EAB generated by the shared Traefik role. Local browsers need the repository CA. Neither host stores a DNS provider credential.

For public ACME staging, override both download_acme_directory and traefik_data_dir (for example a separate acme-staging directory under the Traefik root). Switching back preserves separate staging/production accounts and prevents staging certificates from becoming the production selection.

The content filesystem is Btrfs. The native duperemove package and daily systemd timer share duplicate extents across published files and extracted sites. Its private hash database lives outside both scan roots. The maintenance service waits for the mount and has bounded CPU/memory and low I/O priority; a failed run leaves the release layout and active website selection intact.

The play forces notified handlers after later task failures. Successfully installed configuration therefore still triggers its pending handlers if a later task fails. Unreachable hosts can still prevent handler execution. Nginx configuration validation before installation is unchanged. See Ansible’s handler failure behavior.

3 - XCP-ng

Protected local static-hosting VM

Provisions one Fedora 44 VM in src_infra_download, using named inventory from the existing XCP-ng deployment: two CPUs, 2 GiB RAM, a 20 GiB boot disk, and a 100 GiB content disk. The local address comes from the apex DNS owner, whose local A record is also the target of the unique host CNAME. The cloud template must have one boot disk and the expected enX0 interface; Ansible formats and mounts the second disk at /srv/download without force.

prevent_destroy rejects replacements and destruction because the pinned XO provider owns disks with the VM. There is no automatic retained-disk replacement workflow on XCP-ng. Do not remove that guard as a routine upgrade step.

tf.plan and tf.apply are live operations. tf_tests.fmt_test and the offline entry point with backend-disabled initialization validate source.

4 - Yandex Cloud

Static-hosting VM with retained local content storage

Provisions one Fedora VM in its component folder: two CPUs, 2 GiB RAM, a 20 GiB boot disk, and a separate 100 GiB content disk. The disk and reserved IPv4 address have destruction guards and survive replacement of the VM. The instance firewall permits TCP 22/80/443; Nginx has no public listener.

The delegated Yandex DNS zone’s A record follows the reserved address. Cloudflare delegation and service aliases belong to the sibling DNS root. AL injects the folder ID and folder-scoped service account; no credentials or allocated addresses are copied into checked-in variables.