Automated Server Blueprint & Instant Recovery Declarative Infrastructure & Automated Recovery (debian-ansible)
Before opening new public ticket sales, we fundamentally changed how Highclere Castle's servers are built, secured, and recovered. Instead of relying on a single fragile server maintained by hand over 15 years, every Castle server is now generated automatically from a master digital blueprint that can rebuild a replacement server from scratch in minutes.
Component 2 replaces manual, undocumented server administration with debian-ansible: a declarative,
idempotent infrastructure-as-code blueprint that provisions, hardens, and orchestrates disposable Debian 12/13
hosts, our Traefik v3 + CrowdSec Edge Bastion, our NetBird WireGuard mesh, and
the Git-backed highclere-tickets Docker Compose stack from a single Host-Centric Inventory.
Component 2 eliminates the hidden operational risks of traditional, hand-maintained servers:
- Dismantling the "Jenga Tower" of IT: Traditional servers accumulate years of manual tweaks until nobody dares touch them. Our servers are built 100% from a master blueprint so there are zero hidden surprises.
- Rebuild, Don't Repair: If a server ever suffers a hardware fault or software glitch on a busy Castle opening morning, we do not waste hours troubleshooting it by hand. We launch a clean server and let the blueprint rebuild it automatically in minutes.
- Identical Testing & Live Servers: Our pre-launch Staging server and Live Production server are built from the exact same blueprint, guaranteeing that every feature works identically when promoted to the public.
- Automatic Customer Email Lock on Test Servers: The blueprint automatically detects whether a server is running in Staging or Live mode, locking test servers so they can never accidentally email real customers.
- Dedicated Outer Security Shield: An outer security gateway blocks automated cyber attacks and manages website security padlocks before visitor traffic ever reaches the ticketing database.
"Rebuild, Don't Repair": Why Infrastructure Should Be Disposable
Most traditional IT systems are an accidental game of Jenga. Over months and years, every manual setting change, quick fix, or software update pulls another brick from the tower. The server keeps working for a while, which creates a powerful incentive never to touch it: until the day it suddenly falls.
The Fragile "Snowflake Server"
When a server is configured by hand over 15 years, it becomes a one-of-a-kind Snowflake Server:
- Nobody remembers which undocumented change three years ago keeps a critical script running.
- Applying security updates becomes terrifying because nobody knows what else might quietly break.
- When an outage happens, engineers are not fixing the system: they are performing slow "digital archaeology" under pressure while customers wait.
Something breaks on a busy morning
↓
Log into the old server by hand
↓
Spend hours guessing what changed
↓
Apply a manual patch & hope nothing else breaks Disposable, Reproducible Appliances
We solved this by moving the Castle's configuration outside the server into a permanent, version-controlled master blueprint:
- Never Edit a Live Server by Hand: Every security rule, user account, and website setting is written in the blueprint first.
- Nothing Precious Trapped on the Web Server: Because bookings live safely in the database and code lives in version control, the web server itself is completely replaceable.
- Calm, Predictable Recovery: When a server misbehaves, replacement is a normal 5-minute routine rather than a weekend-ruining disaster.
Something misbehaves
↓
Switch off the degraded server
↓
Run the Master Blueprint on a fresh machine
↓
Verified & back online in minutes As articulated in our engineering philosophy (Rebuild, Don't Repair and the Modulix concept model), imperative infrastructure records history (a fragile sequence of manual SSH edits, package pins, and ad-hoc cron jobs), whereas declarative infrastructure records desired state.
Why In-Place Repair Turns Into Archaeology
- Accumulated Hidden State: The legacy PHP 7.4 server accumulated 15 years of unversioned edits in
/var/www, manual crontab entries, and temporary.gd2barcode files written into the webroot. - Fear of Upgrades: Because no staging environment or provisioning script existed, OS and PHP upgrades were frozen for years out of fear of collapsing the Jenga tower.
- Unbounded MTTR: Recovering from hardware failure or compromise required reconstructing undocumented server history from memory.
Strict Idempotency & Zero Configuration Drift
- Desired State Outside the Machine: Every OS package, sysctl/SSH drop-in, UFW rule, Docker daemon flag, Traefik route, and systemd timer is declared in
debian-ansible. - Idempotent Convergence (
changed=0): Playbooks run unattended (`-i inventory.yaml`). Running `site.yaml` a second time against a converged fleet produces zero mutations. - Stateless Compute vs Persistent Data: Application code is cloned cleanly from Gitea; only PostgreSQL relational state (`postgres_data`) persists across rebuilds.
Declare Intent Once. Compile the Entire Server Stack.
Software developers stopped writing raw machine code decades ago: they write high-level instructions and let a compiler do the heavy lifting. Component 2 brings that same principle to Highclere Castle's servers.
14 Manual Steps Per Server
- Create virtual server by hand
- Set up administrator accounts and passwords
- Configure remote login security settings
- Write firewall rules manually (easy to mistype)
- Configure automatic operating system security updates
- Install container engine and runtime tools
- Install private office VPN software and keys
- Install brute-force login protection
- Download website code onto the server
- Hand-type secret keys and database passwords into config files
- Build and start the website and database containers
- Run database table updates by hand
- Configure background sync and nightly privacy cleanup jobs
- Test everything manually and hope Staging matches Live
Simple Capability Switches
Instead of following a 14-step manual checklist, we declare the server once in our master Host-Centric Inventory and flip on the capabilities it needs:
hs-ct-apps-p-01:
ansible_host: 10.10.30.61
highclere_tickets: true
git_branch: "main"
fail2ban: true
netbird: true Automatically compiled from those 6 lines:
- Hardened Debian OS + automatic security updates
- Private SDN firewall rules + brute-force IP blocking
- Encrypted NetBird office network connection
- Verified website code, PostgreSQL 16 database, 60-second office sync, and 3:15 AM GDPR cleanup timer
Following our ADR-0002 (Host-Centric Inventory and Dynamic Classification) and the Modulix
Capability Translation model, every server is declared exactly once under all.hosts in
inventory.yaml. Operators attach Composable Capability Flags rather than maintaining
brittle, duplicated inventory groups.
| Composable Capability Flag | Implied Prerequisites | Dynamic Target Group | Provisioned Infrastructure & Runtime Scope |
|---|---|---|---|
| Universal Baseline (Implicit) | None | all | roles/common: Unprivileged mad user, admin_users SSH keys,
visudo -cf validation, sshd -t block/rescue hardening, UFW default-deny (allowing 22/tcp),
and systemd apt-daily-upgrade.timer drop-in override.
|
docker: true | None | docker_nodes | roles/docker: Official Docker CE GPG keyring (`/etc/apt/keyrings/docker.asc`), DEB822 repository,
docker-ce + docker-compose-plugin, and mad group membership.
|
bastion: true | docker: true | bastion | roles/bastion: Standalone Compose stack (`/home/mad/docker/bastion`) running Traefik v3 (`v3.7`) +
CrowdSec (`v1.8.1`), DNS-01 wildcard ACME TLS, dynamic Cloudflare `trustedIPs`, and declarative Cloudflare DNS `A` records.
|
fail2ban: true | None | fail2ban_nodes | apps/fail2ban: Hardened `/etc/fail2ban/jail.local` (`backend = systemd`, `bantime = 1h`, `maxretry = 5`)
consuming the shared security_whitelist_ips list.
|
netbird: true | None | netbird_nodes | apps/netbird: Official DEB822 NetBird client package and idempotent WireGuard mesh enrollment
against https://netbird.highclere.tv.
|
highclere_tickets: true | docker: true | highclere_tickets_nodes | apps/highclere_tickets: UFW rule allowing `3000/tcp` strictly from Bastion SDN (`10.10.30.0/24`),
token-authenticated Gitea clone, branch-driven `.env` synthesis (`0600`), `docker-compose.prod.yaml` deployment, Drizzle migrations,
and phased systemd timers (`highclere-etl-sync.timer`, `highclere-retention-prune.timer`).
|
Play 1 Pre-Flight Validation Gate (Fail-Fast Before Opening SSH)
Before opening a single remote SSH connection, Play 1 of site.yaml (gather_facts: false, tags: [always])
executes local ansible.builtin.assert checks across all hosts. If a host enables highclere_tickets: true
with a missing Gitea token, a highclere_tickets_session_secret shorter than 32 characters, or an empty
highclere_tickets_legacy_mariadb_url, execution halts immediately with an explicit remediation message.
Fasthosts Proxmox Fleet & Zero-Trust Network Isolation
Rather than placing the website, database, and staff administration tools on a single exposed machine, Component 2 organises the Castle's digital estate into four distinct, protected zones:
The Castle's Digital Gatehouse
All public internet visitors arrive at our dedicated Edge Bastion server first. It verifies encrypted security certificates and runs an active security shield (CrowdSec) that automatically blocks known hackers, ticket scalping bots, and password guessers at the perimeter.
Protected Internal Application & Database
The live ticketing website and its modern database sit behind a strict internal firewall. They cannot be reached directly from the public internet: they only accept verified visitor traffic forwarded by the outer Gatehouse over a private internal network.
Identical Dress-Rehearsal Environment
Built from the exact same blueprint as the live server and loaded with a full copy of historical bookings. Allows new features (such as Component 3 Ticket Checkout) to be tested safely before going live.
Isolated Office Administration
The old ticketing server is completely disconnected from the public internet. Office staff connect to it exclusively through our encrypted NetBird private office network while the new system copies updates every 60 seconds.
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ PUBLIC INTERNET & CLOUDFLARE EDGE WAF │
│ (highclerecastleshop.co.uk / *.highclerecastle.tv) │
└────────────────────────────────────────────┬─────────────────────────────────────────────┘
│ HTTPS (80/tcp, 443/tcp on bastion_public_interface)
▼
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ EDGE BASTION HOST: hs-ct-bast-p-02 (10.10.30.50 · roles/common + roles/bastion) │
│ • Standalone Compose (/home/mad/docker/bastion): Traefik v3.7 + CrowdSec v1.8.1 │
│ • File-Provider-Only Routing (Zero /var/run/docker.sock exposure) │
│ • Let's Encrypt DNS-01 Wildcard TLS (acme.json 0600) + Cloudflare trustedIPs │
└──────────────────────┬────────────────────────────────────────────┬──────────────────────┘
│ Internal SDN (10.10.30.0/24) │ Internal SDN (10.10.30.0/24)
│ UFW: Allow 3000/tcp ONLY from SDN │ UFW: Allow 3000/tcp ONLY from SDN
▼ ▼
┌─────────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ PRODUCTION NODE: hs-ct-apps-p-01 │ │ STAGING NODE: hs-ct-apps-s-01 │
│ (10.10.30.61 · git_branch: "main") │ │ (10.10.30.60 · git_branch: "staging") │
│ • apps/highclere_tickets (Docker Compose) │ │ • apps/highclere_tickets (Compose) │
│ • Astro SSR (:3000) + PostgreSQL 16 │ │ • Astro SSR (:3000) + PostgreSQL 16 │
│ •NODE_ENV=production │ │ • NODE_ENV=staging │
│ • EMAIL_ALLOWLIST="" (Live Postmark) │ │ • EMAIL_ALLOWLIST=staff@... (Locked) │
│ • highclere-etl-sync.timer (Every 60s) │ │ • Mirrored 506 MB PostgreSQL dataset │
│ • highclere-retention-prune.timer (03:15) │ │ │
└──────────────────────┬──────────────────────┘ └─────────────────────────────────────────┘
│
│ Outbound MySQL (3306/tcp over Internal SDN / NetBird wt0)
│ Read-Only Incremental Batch Poll (Every 60s)
▼
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│ QUARANTINED LEGACY LAMP VM (10.10.30.20 · Zero Public Ingress) │
│ • Legacy PHP 7.4 (/admin) reachable ONLY by Castle Staff over NetBird WireGuard Mesh │
│ • Legacy MariaDB 10.11 read by hs-ct-apps-p-01 ETL sync until Component 4 cutover │
└──────────────────────────────────────────────────────────────────────────────────────────┘ Single-Variable Branch Synthesis & Automatic Email Safety Locks
One of the biggest risks when testing a new ticketing system with real historical customer records is accidentally sending test emails to real visitors. Component 2 makes that mistake impossible by tying email safety directly to the server's role in the blueprint:
| Setting | Staging Server (hs-ct-apps-s-01) | Live Production Server (hs-ct-apps-p-01) |
|---|---|---|
| Blueprint Branch Switch | staging (Pre-launch testing version) | main (Verified public version) |
| Website Address | Private staging web address | Official public website address |
| Customer Database | Full 906,406-record copy for realistic testing | Live 906,406-record database synced every 60s |
| Outbound Email Safety Lock | Locked to Castle Staff Only Automatically blocks emails to the public; only approved Castle staff & family addresses can receive sign-in codes. | Live Customer Delivery Delivers instant 6-digit sign-in codes and ticket confirmations to all customers via Postmark. |
In apps/highclere_tickets, the host's git_branch variable (defaulting to "main")
controls both the Gitea repository checkout and the Jinja2 synthesis of /home/mad/docker/highclere-tickets/.env
(mode '0600', owned by mad:mad, rendered with no_log: true):
# apps/highclere_tickets/templates/env.j2
APP_BIND_IP={{ highclere_tickets_bind_ip }}
{% if git_branch == 'main' %}
NODE_ENV=production
PUBLIC_SITE_URL=https://{{ bastion_domain }}
{% elif git_branch == 'staging' %}
NODE_ENV=staging
PUBLIC_SITE_URL=https://staging.{{ bastion_domain }}
{% else %}
NODE_ENV=development
PUBLIC_SITE_URL=https://{{ git_branch }}.{{ bastion_domain }}
{% endif %}
PORT={{ highclere_tickets_port }}
SESSION_SECRET={{ highclere_tickets_session_secret }}
POSTGRES_USER={{ highclere_tickets_postgres_user }}
POSTGRES_PASSWORD={{ highclere_tickets_postgres_password }}
POSTGRES_DB={{ highclere_tickets_postgres_db }}
DATABASE_URL=postgresql://{{ highclere_tickets_postgres_user }}:{{ highclere_tickets_postgres_password }}@postgres:5432/{{ highclere_tickets_postgres_db }}
LEGACY_MARIADB_URL={{ highclere_tickets_legacy_mariadb_url }}
EMAIL_MODE={{ highclere_tickets_email_mode }}
POSTMARK_SERVER_TOKEN={{ highclere_tickets_postmark_server_token }}
EMAIL_FROM={{ highclere_tickets_email_from }}
EMAIL_ALLOWLIST={{ highclere_tickets_email_allowlist if git_branch != 'main' else '' }}
CONTACT_EMAIL_DEFAULT={{ highclere_tickets_contact_email_default }}
COMPANY_VAT_NUMBER={{ highclere_tickets_company_vat_number }}
Because EMAIL_ALLOWLIST is populated automatically whenever git_branch != 'main',
and enforced inside PostmarkEmailTransport at the application layer, a staging or development
node can never send live emails to any of the 226,104 migrated customer addresses even if configured with a
live Postmark token.
The 4-Step Disaster Recovery Procedure
If a server ever suffers a critical hardware failure or software corruption, recovery follows a calm, repeatable 4-step procedure that requires zero guesswork:
Start a Clean Virtual Machine
Instead of trying to repair the broken server, we switch it off and launch a fresh, empty Debian server on our cloud hosting platform.
Execute One Automated Command
We run our single master blueprint command. It automatically configures all security firewalls, installs the private VPN, downloads the latest verified website code, and starts the database.
Reload Customer & Ticket Data
The database is instantly populated either from our encrypted daily backup or by running the automatic synchronization bridge from the office database.
Verify 6 Safety Checks
Six built-in verification checks confirm that the firewall is locked, the website is responding, and the 60-second background sync timer is ticking normally.
To rebuild a degraded or destroyed application node (hs-ct-apps-s-01 or hs-ct-apps-p-01)
from a bare Debian 12/13 installation:
# 1. Bootstrap clean Debian VM from Ansible controller (initial run as root creates 'mad' & hardens SSH)
ansible-playbook site.yaml -i inventory.yaml --limit hs-ct-apps-p-01 -e "ansible_user=root"
# 2. Subsequent runs connect seamlessly as unprivileged 'mad' user with zero drift (changed=0)
ansible-playbook site.yaml -i inventory.yaml --limit hs-ct-apps-p-01
# 3. Restore PostgreSQL state (either from compressed pg_dump or full idempotent MariaDB ETL import)
gunzip -c highclere_tickets_backup.sql.gz | \
docker compose -f /home/mad/docker/highclere-tickets/docker-compose.prod.yaml exec -T postgres \
psql -U highclere highclere_tickets
# OR reconstruct directly from legacy MariaDB:
docker compose -f /home/mad/docker/highclere-tickets/docker-compose.prod.yaml exec -T app \
npm run db:sync-legacy -- --full
# 4. Run the 6-Point Post-Install Verification Checklist on the target host:
git -C /home/mad/docker/highclere-tickets status
sudo ls -l /home/mad/docker/highclere-tickets/.env # Must be -rw------- (0600) mad:mad
sudo ufw status verbose | grep 3000 # Allowed ONLY from 10.10.30.0/24
docker compose -f /home/mad/docker/highclere-tickets/docker-compose.prod.yaml ps
curl -sI http://127.0.0.1:3000/api/health # HTTP/1.1 200 OK
systemctl list-timers 'highclere*' # ETL (60s) & Prune (03:15 UTC) active How We Prevent Blueprint Mistakes & Administrator Lockouts
When a single blueprint controls every server, a mistake in the blueprint itself could be dangerous if left unchecked. We protect the blueprint with strict automated safety locks and a 3-stage review process:
Agree the Specification
Before any server automation code is written, the exact purpose, security rules, and recovery steps are written down in our 7-section operational guide.
Automatic Rollback Guards
Every security change includes a built-in safety net. For example, remote login settings are tested automatically before being applied so administrators can never be accidentally locked out.
10-Point Security Verification
An independent verification check audits every change to confirm secret keys are hidden from logs, firewalls are tight, and running the blueprint twice produces zero unintended side effects.
Mandatory 3-Stage Agent Pipeline
All changes to debian-ansible execute sequentially across isolated specialist sub-agents
(preceded by Stage 0: Researcher when auditing upstream releases):
- Stage 1 (Documenter): Updates
CONTEXT.md, ADRs, and the mandatory 7-section guide indocs/roles/ordocs/apps/first. - Stage 2 (Generator): Implements tasks using strict FQCN modules (
ansible.builtin.*,community.docker.*,community.general.*), explicitansible_facts['...']syntax, and atomic commits. - Stage 3 (Validator): Verifies
--syntax-check,yamllint,no_log: truesecret hygiene, and two-run idempotency (changed=0).
Anti-Lockout & Secret Protection Guards
- SSH Block/Rescue Validation:
roles/commondeploys00-hardening.confinside an Ansibleblock/rescueconstruct verified bysshd -t; if validation fails, the drop-in is automatically removed before restartingssh. - Sudoers Syntax Check: All
/etc/sudoers.d/files enforcemode: '0440'andvalidate: 'visudo -cf %s'. - Unified Security Whitelist: Administrative origins in
security_whitelist_ipsare shared across Fail2ban and Bastion CrowdSec/Traefik allowlists. - Zero Secret Leakage: Every task handling password hashes, Gitea tokens, NetBird setup keys, Cloudflare API tokens, or
.envtemplates enforcesno_log: true.