Skip to content

Anatomy of a Stack ​

An application in Productify is not one process. It is a stack: one primary service, any number of backend services, and any number of managed data stores. One application record owns all of them, they deploy together, and they are served behind one hostname.

For the rules that decide which service receives a request, see Service Routing Rules. For the data stores in detail, see Managed Data Dependencies.

The pieces ​

Application "shop"
├── Service  shop        (primary)   ← the vhost's catch-all
├── Service  api         (backend)   route_prefixes: /api
├── Service  optimizer   (backend)   route_prefixes: (none) → INTERNAL
├── Data     main        postgres 16 → DATABASE_URL
└── Data     cache       redis 7     → REDIS_URL

Services ​

Every application has exactly one primary service. It carries the application's own slug, it is the target of the vhost's catch-all route, and it cannot be deleted. The Manager creates it when you create the application.

Backend services are added with pfy service create <slug>. A backend is a separately built, separately deployed process that shares the application's hostname, tenants and identity. It is not a separate application: it has no domains of its own, no separate tenant enablement, and no separate place in the dashboard.

bash
pfy service create api --app shop --route-prefix /api
pfy service create optimizer --app shop        # no prefixes → internal
pfy service list --app shop                    # primary first

A backend with no route prefixes is internal: it gets no route and no hostname, and nothing outside the cluster can reach it. Other services reach it by its Nomad service name. See Service Routing.

A service's kind (primary or backend) is fixed at create time and cannot be changed. Its slug can be changed with pfy service update <slug> --slug <new>, which changes every name derived from it (job, Nomad service, upstream).

Deployments ​

A Deployment is the (service × environment × tenancy) row that holds a deployable spec: image, resources, ports, env, migration, scaling. One service has one deployment per environment, plus one more per dedicated tenant in that environment (see Shared vs. Dedicated Tenants).

So a two-service application in two environments has four deployment rows, and each dedicated tenant adds two more. Every CLI command that reads a spec addresses one row at a time:

bash
pfy deployment get --env prod                  # the primary's shared row (the default)
pfy deployment get --env prod --service api    # the backend's shared row
pfy deployment get --env prod --tenant acme    # the primary's dedicated row for acme

--service defaults to the primary.

Managed data stores ​

A data dependency is a postgres or redis instance the platform provisions, runs, credentials and backs up for the application. You declare it once on the application; the platform runs one instance per (environment, dedicated tenant):

bash
pfy data create main --app shop --kind postgres --version 16 --volume shop-main-pg
pfy data create cache --app shop --kind redis --version 7

Each service that has not opted out receives a connection URL for every store as an environment variable: DATABASE_URL and REDIS_URL for the oldest store of each kind, DATABASE_URL_<SLUG> / REDIS_URL_<SLUG> for later ones. The password is never in the rendered job; the job resolves it at start-up from a Nomad variable. See Managed Data Dependencies.

Names ​

Every Nomad job name in a stack derives from the application slug, so you can predict the names from the Manager's data alone.

ThingJob nameExample
Primary service<app>shop
Backend service<app>--<service>shop--api
Dedicated tenant's row…--<tenant>shop--acme, shop--api--acme
Data store<app>--<store>shop--main
Data store, dedicated<app>--<store>--<tenant>shop--main--acme
Backup job<job>--backupshop--main--backup, shop--main--acme--backup

The Manager owns the derivation (deployspec.EffectiveUpstream and deployspec.DataDepJobName). The CLI reads the resolved upstream off the deployment rather than re-deriving it, so there is one rule and nothing to drift. The -- separator is unambiguous because a slug may not contain two dashes in a row. An upstream_override on a deployment replaces the derived name, and then pfy nomad deploy --name supplies the job name.

Applying the whole stack ​

pfy deploy diff is drift diagnosis only. It compares the deployment spec against what is committed and what is running, and changes nothing. Applying a stack is pfy nomad deploy --stack.

bash
pfy nomad deploy --stack --env prod --yes

It renders every job in the stack, writes them to deploy/ (or --out-dir), and applies them in stages:

  1. Data stores. Every managed store, ordered by job name. If a store's credential does not exist yet, the CLI seeds it first, before the store's job is submitted. The store reads the password at start-up through a template stanza, so a variable written afterwards would only take effect on the next restart.
  2. Services. The primary first, then the backends by job name. The primary is the vhost's catch-all, so a stack apply that fails halfway has at least left the application reachable. A service's prestart migrate task runs between the stages by construction: Nomad starts it before the service's own task, and the stage ordering has already put the store up.
  3. Backup jobs. Last. A backup job registered before its store exists would have its first scheduled run fail against nothing, and an apply that fails on a backup job has at least left the application serving.

Health gating between stages ​

Between one stage and the next, the apply waits for every job in the stage it just applied to become healthy, and stops if one does not.

This matters because nomad job run returns once a job is registered and its allocations are placed, not once anything inside them is answering. Without the gate, an apply in perfect dependency order would still submit the service the instant the store's job exists, and the service's prestart migration would dial a Postgres that is still initialising its data directory. The apply would report success and the application would crash-loop.

"Healthy" is Nomad's own verdict. Every rendered store registers a service check (pg_isready for postgres, an authenticated PING for redis), and a service job's deployment only reaches successful once those checks have passed. A job with no deployment (batch, periodic) falls back to allocation status, and zero allocations is not healthy.

FlagEffect
--wait-timeoutHow long each stage has to come up. Default 5m.
--no-waitSkip the gate entirely. Faster against a stack whose stores are already up. A service applied this way can start before its database accepts connections.

A timeout stops the apply and says so. The later stages are not applied, and the message names the job that never came up.

Narrowing ​

FlagApplies
(none)The whole stack.
--only-dataThe managed stores and their backup jobs, no services.
--service <slug>Exactly that one service's job. Stores are untouched.

--only-data keeps the backup jobs on purpose: a backup job is the store's job, not a service's. Passing both flags together is refused rather than intersected, because they select disjoint sets and intersecting them would apply nothing and report success. A misspelled --service is an error that lists the stack's services, never a quiet fallback to the whole stack.

--dry-run prints every rendered job without writing or submitting anything. --plan-only renders, validates and plans without running. Neither waits on health, because nothing was submitted.

What the proxy sees ​

The whole stack is one vhost. The primary is the catch-all, each routed backend is a route block ahead of it, and internal services appear nowhere. The Manager assembles and pushes that config itself. See Proxy Vhost Sync Loop.