Skip to content

Generated Deployment Artifacts ​

In a Productify install no deployment artifact is hand-authored. Application Nomad jobs, the platform-core stack (Manager, proxy, identity provider, database), and the entire Caddyfile (prelude and per-application vhosts) all render from structured inputs. You maintain a deployment spec and an install.yaml, not HCL and Caddyfiles. Below: where each artifact comes from, which pfy command renders it, and how drift is detected.

One model: full render + structured overrides

Artifacts are always regenerated from their source. Hand edits are drift and are overwritten; CI fails on a modified GENERATED file. Customization happens through structured spec fields and the delimited extra_* escape hatches, never by editing output.

Where each artifact comes from ​

ArtifactSource of truthCommand
Application Nomad jobDeployment spec on the Manager's Deployment entitypfy nomad render | deploy
Autoscaler scaling blockThe same deployment spec (scaling)rendered into the app job
Platform-core stack (Manager, proxy, Pocket ID, Postgres)Local install.yamlpfy platform render | deploy
Caddyfile prelude (globals, OAuth portal, Manager vhost)Local install.yamlpfy platform render
Per-application vhostsManager data (Deployment.domains + upstream)pushed live via Caddy admin API

The platform-core artifacts render from a local install.yaml, not from the Manager: the Manager cannot be its own prerequisite (the bootstrap exception). Everything else renders from the deployment spec that lives on the Manager and is UI-editable.

The GENERATED header and determinism ​

Every rendered file carries a header and is byte-identical for the same spec and template version (no timestamps):

# GENERATED by pfy vX.Y.Z from deployment spec <hash> — do not edit; edits are overwritten
# GENERATED by pfy vX.Y.Z from install.yaml <hash> — do not edit; edits are overwritten

(The first form is on application jobs, the second on platform-core files. A sample rendered set is committed in deployments/examples/qa.productify.dev/rendered/.)

  • No secrets, ever. Secrets are referenced (Nomad template stanzas, {env.*} in the Caddyfile), never inlined, so rendered files are safe to commit.
  • The spec hash is a canonical-JSON SHA-256 over the spec, exposed in the API and stamped into output headers.
  • Escape hatches are structured spec fields (extra_hcl, extra_caddy_global, extra_vhosts), appended verbatim and clearly delimited. Anything beyond them is a template feature request, not a hand edit.

The deployment spec ​

The spec is structured, validated data on the Deployment entity (per application × environment), edited in the Manager UI or via pfy. Every mutation is audit-logged (actor, before/after). Fields (all validated at write time):

FieldNotes
imagerepo/name, no tag; the tag comes from the release being deployed
ports[{name, to, static?}]
health{liveness_path, readiness_path, interval, timeout} (paths start with /)
resources{cpu_mhz, memory_mb} (floors enforced)
countwith min ≤ count ≤ max when scaling is on
scaling{enabled, min, max}: renders the Nomad scaling block with the productify-scaler strategy. min must be at least 1: min: 0 is gated behind a scale_to_zero flag no API can set (Known Issues). The rendered strategy block carries no cache_size, so it takes the plugin default; read that entry before relying on autoscaling. It renders metric_app_name (the application UUID that labels the pfy_* demand series) alongside group and task (the Nomad identifiers the per-replica capacity queries need). A job rendered without task cannot measure its own capacity and must be re-rendered and redeployed (Known Issues)
env[{name, from_nomad_var}]: references only; literal values rejected for secret-pattern names
migrate{enabled, command}: prestart migration task
datacenters, namespaceNomad placement
extra_hcldelimited verbatim block
domainsproxy hostnames for this (application, environment)
upstream_overridefor backends not deployed via pfy (external hosts)

Access goes through the Manager's authorization helpers: write requires project maintainer or above; read is available to members.

Editing the spec from the CLI ​

bash
pfy deployment get --env run -o yaml
pfy deployment set --env run --field resources.memory_mb=512
pfy deployment set --env run --file spec.run.yaml        # bulk apply

# Opt-in git-review workflow
pfy deployment snapshot --env run                        # writes deploy/spec.run.yaml
pfy deployment apply deploy/spec.run.yaml                # round-trips it back

Because the spec bypasses git review, snapshot/apply give teams a commit-anchored review trail, and deploys print the spec-hash delta since the last applied hash.

Rendering application jobs ​

bash
# Render the full Nomad job HCL from the spec
pfy nomad render --env run --tag v1.4.2

# render → nomad job validate → nomad job plan → nomad job run
pfy nomad deploy --env run --use-project-version

pfy nomad update-image is the legacy in-place image = rewriter for hand-written HCL you have not adopted yet; use pfy nomad deploy for anything rendered from a spec. pfy nomad deploy --stack applies an application's whole stack (managed data stores first, then their backup jobs, then the services) in dependency order. The rendered job includes ports, Nomad service registration (the name the vhost renderer consumes), liveness/readiness checks, internal-only networking, template stanzas for env references, an optional migration prestart task, the scaling block, and any extra_hcl.

Rendering the platform stack ​

The platform stack renders from a single install.yaml: domains (manager, id, apps wildcard), datacenter/region, volumes, pinned image versions, TLS mode, optimizer endpoint, and resource sizes:

bash
pfy platform render --target nomad       # or: --target compose
pfy platform deploy                       # applies (nomad)

The Compose target renders from the same templates the self-hosted bundle is built from, so the two track each other, but the shipped bundle is not a byte-for-byte copy of a render: it carries the domain and secret variables an install needs. The rendered set includes the proxy job whose Caddyfile prelude comes from the same install.yaml. Operator extension points are structured (extra_caddy_global, extra_vhosts), matching the extra_hcl pattern.

proxy_admin_bind (optional, top-level) ​

The bare IP the proxy binds its Caddy admin API (2019) and its metrics/probe server (2112) to on a Nomad node.

Empty (the default) uses the node's own allocated address (${NOMAD_IP_admin}), the narrowest address a bridge-networked Manager can still reach. Loopback is not an option there: the proxy group is mode = "host" and the Manager group is mode = "bridge", so 127.0.0.1 in the Manager's namespace is the Manager.

Set it to a private interface when the node's own address is public, which is the normal case on a single-NIC cloud host. Caddy's admin API has no authentication and POST /load replaces the entire configuration, so whoever reaches 2019 controls the proxy fronting every application and tenant.

A bare IP only: no port (2019 and 2112 are fixed), no hostname, no scheme. 0.0.0.0 and :: are rejected rather than accepted-and-ignored.

Compose ignores it: the compose bundle publishes only 80 and 443, so its wildcard bind is contained by Docker and the ports are reachable on the compose network alone.

Proxy configuration & drift ​

The Caddyfile is entirely rendered:

  • Install-level parts (global options, the productify app block with the Manager URL and {env.PFY_MANAGER_TOKEN}, the caddy-security OAuth2/OIDC portal, the Manager's own vhost, metrics) render from install.yaml at platform-deploy time.
  • Per-application vhost blocks render from Manager data (Deployment.domains + upstream) as productify_protect <app_id> + reverse_proxy <upstream>, and are pushed live to the running proxy via Caddy's admin API on any relevant change.

Drift is detected via a config hash (one hash, whole config) compared against the Manager's last-rendered hash:

bash
pfy proxy status      # is the loop on, healthy, converged?
pfy proxy diff        # compare the rendered hash with the applied hash (--exit-code: 3 on drift)
pfy proxy sync        # ask the Manager to render and push now
pfy deploy diff --env run   # three-way: spec vs committed artifact vs running cluster

All three pfy proxy commands need a system-administrator token. diff compares hashes; the proxy's running config text is not exposed, so there is no line-by-line diff of live config.

A rejected config keeps the last good state (never a partial apply), surfaces the rejection, and retries with backoff; a fresh proxy triggers a startup reconcile push. The renderer stamps a minimum proxy version; a proxy that does not understand the config rejects it loudly, which is the version handshake that prevents silent misrouting across a version mismatch.

Migrating a hand-authored install ​

If you are coming from a hand-written deployment, for example the first consumer's deploy/nomad/mytask.hcl plus an in-repo proxy vhost Caddyfile:

  1. Adopt the job into a spec. pfy nomad adopt <jobfile.hcl> --env <env> reads the existing job file, extracts what maps onto the spec (image, ports, checks, resources), writes the spec to the Manager, renders, and prints the residual diff for manual triage into extra_hcl or a template request. --dry-run previews without writing.
  2. Move the vhost into Manager data. The in-repo Caddyfile vhost becomes Deployment.domains + upstream; the vhost block renders and pushes automatically. Delete the hand-written block.
  3. Delete the hand-authored artifacts. Once pfy deploy diff is empty, remove deploy/nomad/mytask.hcl and the in-repo vhost. From then on, the spec is the source of truth and CI fails on any re-introduced hand edit.
  4. Convert the platform stack. Write install.yaml for your platform install, pfy platform render, diff against the live jobs, reconcile intentional differences into spec/template fields until the diff is empty, then delete the hand-maintained *.nomad.

The exit criterion is zero hand-written HCL or Caddyfile anywhere in the repo or on the box.

See Also ​