HyprBox docs GitHub ↗

Presets

A preset is a YAML file in presets/ that describes an infrastructure recipe (hardening baseline, monitoring stack, web app). The API loads them at boot, validates them against the Zod schema in apps/api/src/lib/presets/schema.ts, and renders them to idempotent bash on demand.

Top-level shape

name: server-light                    # kebab-case, must match the filename
version: 0.1.0
description: >
  Multi-line description. Surfaces in the catalogue card and the rendered
  bash header.
tags: [server, security, baseline]    # free-form labels
targets: [debian, ubuntu]             # distros the renderer's guard accepts
requires: [sudo, apt]                 # documented assumptions (not enforced)
risk_level: CONFIRM                   # see "The Fix contract" below

variables:
  ssh_port:
    description: TCP port for sshd. Keep 22 unless you're moving SSH off the default.
    default: 22
  hostname:
    description: Set the system hostname. Leave empty to keep the current one.
    default: ""

steps:
  - name: Install baseline security packages
    type: package
    packages: [ufw, fail2ban, unattended-upgrades]
  # ... more steps

verify:                               # optional but recommended for Fixes
  - name: sshd is active
    type: command
    run: systemctl is-active --quiet ssh

Required fields

  • name — must be [a-z0-9-]{2,40} AND match the filename without extension. Mismatch is a hard error at load time.
  • description — 1 to 280 chars.
  • steps — at least one. Order matters; steps run sequentially.
  • tags — used to filter the catalogue UI.
  • targets — populates the runtime distro guard. Anything outside the list refuses to run unless HYPRBOX_FORCE=1.
  • variables — gives the user form fields in the dashboard and CLI -V flags.

Variables

Each variable is { description?, default?, required? }. Reference them in strings via {{ var_name }}. The renderer substitutes at render time — not at runtime — so the resolved values get baked into the script.

variables:
  domain:
    description: Domain name pointing at this server (required for HTTPS).
    required: true

steps:
  - name: Write Caddyfile
    type: config_file
    path: /opt/hyprbox/stacks/web/Caddyfile
    content: |
      {{ domain }} {
          reverse_proxy app:80
      }

If required: true and the user didn't provide a value, the API returns 400 Render failed. If required: false (default) and no default is set, the variable resolves to an empty string.

Step types

All steps accept two common fields:

  • name — label shown in the CLI / UI / rendered bash header.
  • when — runtime guard. The renderer emits an if check; if the substituted value is empty, "0", or "false", the step prints skipped: when-guard fell through and continues.

package

- type: package
  packages: [ufw, fail2ban, curl]

Emits apt-get update -qq && apt-get install -y -qq .... Apt only — the distro guard ensures we're on Debian/Ubuntu before this can fire.

firewall

- type: firewall
  reset: true              # default-deny baseline before applying rules
  allow:
    - port: 22
      proto: tcp
      comment: SSH
    - port: 80
      proto: tcp
      from: 10.0.0.0/8     # optional CIDR allowlist
      comment: HTTP from VPN only
    - "8080/udp"           # shorthand string also works

Emits ufw --force reset (if reset: true), then ufw default deny incoming / allow outgoing, then each rule, then ufw --force enable.

ssh_harden

- type: ssh_harden
  port: 22                       # accepts variables like "{{ ssh_port }}"
  disable_root_login: true
  disable_password_auth: true
  allow_users: [hyprbox-ops]     # optional AllowUsers directive

Backs up /etc/ssh/sshd_config, applies the changes via sed, validates with sshd -t before restarting the service. You're responsible for having an authorized SSH key set up before applying this — if password auth is the only way in, this preset will lock you out.

automatic_updates

- type: automatic_updates
  scope: security        # 'security' (default) or 'all'
  reboot: false          # auto-reboot after upgrades?
  reboot_time: "04:00"

Configures unattended-upgrades. Safe to apply more than once.

config_file

- type: config_file
  path: /etc/hyprbox/agent.conf
  mode: "0644"
  owner: root
  group: root
  content: |
    api_url={{ api_url }}
    interval=5m

The content is dropped via a tee heredoc with a quoted sentinel, so it's shell-literal (no $ expansion at runtime). Variables ARE substituted at render time.

command

- type: command
  run: systemctl reload nginx
  fail_on_error: true        # default true — non-zero exit aborts the run

Escape hatch for anything not covered by a typed step. Avoid when possible — typed steps are easier to audit and test.

docker_compose

- type: docker_compose
  target_dir: /opt/hyprbox/stacks/web
  env:
    DOMAIN: "{{ domain }}"
    POSTGRES_PASSWORD: "{{ postgres_password }}"
  compose: |
    services:
      caddy:
        image: caddy:2.8
        ports: ["80:80", "443:443"]
        # ...

Writes compose.yml and .env to target_dir, then runs docker compose pull && up -d in that directory. The env file is rebuilt every apply, so remove a key from env: to drop it from the stack.

hostname

- type: hostname
  hostname: "{{ hostname }}"

Calls hostnamectl set-hostname. Combine with a when: guard if you want to skip when the variable is empty:

- when: "{{ hostname }}"
  type: hostname
  hostname: "{{ hostname }}"

Rendered bash anatomy

Every script starts with:

  1. #!/usr/bin/env bash + set -euo pipefail.
  2. A comment header with preset name, version, description, and the resolved variable map.
  3. Helpers — log, ok, warn, run_step <i/N> <label> <fn>, step_skip.
  4. A guard_distro function that reads /etc/os-release, refuses unknown distros, and respects HYPRBOX_FORCE=1 for override.
  5. step_NN() functions in declaration order.
  6. The actual runner: run_step "1/N" "<label>" step_01 lines.

Output looks like:

[12:34:56] ─── [1/5] Install baseline security packages
[12:35:02] ✓ [1/5] Install baseline security packages
[12:35:02] ─── [2/5] Default-deny firewall, allow SSH + web
...
[12:35:30] >>> Done — preset 'server-light' applied successfully.

A failed step aborts the run (set -e) and prints [i/N] <label> — FAILED.

Writing your own preset

  1. Drop a <name>.yaml in presets/. The filename must match the name: key.
  2. Restart the API so the loader picks it up: pnpm dev:api or docker compose -f docker-compose.prod.yml restart api.
  3. Verify it loads: curl /api/presets should list it.
  4. Preview the rendered bash without applying:
    hyprbox preset preview my-preset -V foo=bar -V port=2222
    
  5. Test on a throwaway VM. The guard_distro step will refuse to run on the wrong OS — that's intentional. Use HYPRBOX_FORCE=1 to bypass when you're sure.

Best practices

  • Idempotent steps: a preset should be safe to re-apply. ufw allow X is idempotent; echo line >> file is not (use tee -a with a content check or stick to the typed steps).
  • One concern per step: easier to read in the activity log and easier to rerun a single failed step manually.
  • Variables for anything that varies per host: hostname, ports, allowed IPs. Hard-coded values mean a new preset per environment.
  • Document required: true variables: the description shows up in the dashboard form and in the API response — it's the only hint the user gets.
  • Don't bake secrets into the YAML: pass them as variables at queue time; they end up in the job row but at least not in the repo.

The Fix contract

A preset that's the target of a Recommendation is a Fix. Fixes conform to a stricter shape than vanilla presets, so the operator can trust them:

Field Purpose Default
risk_level UI gating for Apply CONFIRM
steps The actual change (existing field) required
verify Post-apply assertions. Failure → job FAILED, even if steps passed. []

risk_level

Value What it means UI behaviour
SAFE Read-only or strictly idempotent reversible no-op. Apply with no confirm
CONFIRM Default. State change, easy to undo. Modal confirm + show plan
DANGEROUS Can lock you out or destroy data. Modal + type the hostname to confirm
MANUAL We describe the fix but won't run it. No Apply button, instructions only

When in doubt: pick CONFIRM. Promote to SAFE only for changes that are genuinely no-ops if applied twice (e.g. caddy reload triggering a no-op renewal). Promote to DANGEROUS for anything that touches sshd, firewall, or persistent data.

verify steps

verify: accepts the same step types as steps: (typically command:). They run after steps: and decide the job's terminal status:

  • Every verify step exits 0 → job is SUCCEEDED.
  • Any verify step exits non-zero → job is FAILED (with exit code 2 from the script — the renderer's run_verify helper translates the failure into a clearly-labelled "the fix ran but did not take effect" message in the output stream).

This separation matters. Without verify, "succeeded" means "no command errored". With verify, it means "no command errored AND the change landed". That's the difference between a deploy script and a Fix.

Good verify steps assert the goal, not the mechanism:

# good — asserts the post-condition
verify:
  - name: Port 443 is listening
    type: command
    run: ss -tln | grep -q ':443'

# bad — restates a step that already happened
verify:
  - name: Caddy was reloaded
    type: command
    run: echo "we reloaded caddy"

A reasonable cadence: verify: should take less than 30 seconds total. For "wait for the service to come back" use sleep N in the last steps: entry, not in verify:.

What about rollback?

Not yet in the schema. When apply succeeds but verify fails, the operator sees a clear error and decides; we don't automatically roll back. The explicit rollback: block lands when we have a fix complex enough to benefit from it (e.g. an SSH hardening preset with multiple sshd_config patches). For now, idempotent forward-fixes (caddy reload, restic backup) don't need rollback because re-running them converges anyway.

Built-in presets

  • server-light — Debian/Ubuntu hardening: UFW (default deny + SSH/HTTP/HTTPS), fail2ban, unattended-upgrades (security only), sshd_config lockdown.
  • monitoring-only — Prometheus + Grafana + Alertmanager + node-exporter via docker compose. Opens ports 3001/9090/9093.
  • pme-web — Caddy reverse-proxy with automatic Let's Encrypt + Postgres
    • an app container of your choice ({{ app_image }}). Requires domain.
  • hypervault-restic — installed by the HyprVault page; wires Restic
    • a per-policy cron + start/complete reporting to the API.
  • caddy-tls-renew — Fix recipe (risk_level: SAFE). Recommended by the tls.expiring finding. Reloads Caddy then verifies the certificate has at least min_days_after_renewal days of validity.