Zero-to-App Local Quickstart
From an empty machine to an app running against a local Productify platform: the platform starts with one command, an example app is wired to it, and a trigger fires on your machine. Use it the first time you develop against Productify, or whenever you need a throwaway local platform.
What you need
- Docker with the Compose plugin (
docker compose versionworks). - The
pfyCLI on yourPATH. - These host ports free:
5432,8080,8081(Manager + database), plus80,443,1411,2112for the full stack.pfy dev upchecks them first and refuses to start if one is taken (for example a PostgreSQL service already listening on5432). Ports it may not bind because they are privileged (80and443on Linux) are skipped, not reported as conflicts; Docker publishes those from a root daemon.
pfy dev up writes its Compose file and a dev Caddyfile into .pfy/dev/ in the current directory (and gitignores them), then starts images from ghcr.io/productifyfw/*, ghcr.io/pocket-id/pocket-id and postgres. The image tags are pinned inside your pfy build (pfy 2.0.0 pins manager 2.0.0, proxy 1.1.0, Pocket ID v2.14.0 and postgres 16.4); override them with pfy dev up --version manager=<tag>,proxy=<tag>,pocketid=<tag>,postgres=<tag>.
The two dev loops
Productify supports two local development loops. Know which you are in:
| Loop | What runs | Identity | Use it for |
|---|---|---|---|
| Bare | Just your app + the tiny pfy dev proxy (or the @productifyfw/dev Vite shim) | A fixed dev identity you choose, injected as the production header set | Pure domain/UI work: fast inner loop, no containers, no OIDC |
| Full-stack | The whole platform (Manager, database, Pocket ID, real proxy) | Real login through Pocket ID | Auth-adjacent work, tenant switching, end-to-end behavior |
The bare loop gives you the exact production header contract without the four containers or the OIDC login flow. Most integration bugs show up there. The full-stack loop is the real thing. Both are walked below, in order.
1. Bring up the platform and seed it
pfy dev up --seedThat is exactly pfy dev up && pfy dev seed, with a wait for the Manager's API in between. Run the two separately when you want to pass seed options: --seed takes none of its own.
pfy dev upwrites (if absent) and starts the pinned-version Compose stack (Manager + PostgreSQL + Pocket ID + proxy) under the Docker Compose projectpfy-dev. It detaches by default; pass--detach=falseto keep it in the foreground, which cannot be combined with--seed. Add--profile minimalto start only Manager + database for the bare loop.- If the stack starts and seeding fails, the stack stays up and the command exits non-zero. Fix the cause and run
pfy dev seedon its own. - Seeding creates a project, a
devtenant, an application, the tenant's enablement for that application, adevenvironment and a machine user in it, then writes two generated files (see Generated files). Re-running it is safe; it creates nothing that already exists. Defaults: projectpfy-dev(orproject.yaml'sproject_name, capped at 10 characters), appdev-app, tenantdev.
Check health any time:
pfy dev statusWhich Manager seeding talks to
pfy dev seed finds the running dev stack on its own: it reads .pfy/dev/compose.dev.yml for the Manager's published port and the stack's PFY_PROXY_SECRET, and authenticates as that stack's trusted proxy. The order is --manager, then PFY_MANAGER_URL, then the local dev stack, then ~/.pfy/config.yaml. A running dev stack outranks the stored config so a pfy login against a real deployment cannot make seeding create projects there, and a token stored for another Manager is never sent to the dev stack.
Do not set PFY_MANAGER_URL for this. It is honoured verbatim, and pointing it at a remote Manager makes pfy dev seed write to that one with your stored token.
Generated files
pfy dev seed writes two files into the current directory and gitignores both. They hold dev-only values, never production credentials:
| File | Purpose | Consumed by |
|---|---|---|
.env.productify.dev | MANAGER_URL, APPLICATION_ID, MACHINE_USER_TOKEN, and PROXY_SHARED_SECRET (the running stack's secret, or the one you pass to --proxy-secret). Written with mode 0600 because it carries the machine-user token. | Your backend, pfy dev proxy |
dev.productify.json | A fake window.__PRODUCTIFY__ payload with a dev users list | The @productifyfw/dev Vite shim, pfy dev proxy |
One dev.productify.json feeds the Vite shim, the dev proxy and your tests, so local identity has one source of truth.
2. Get the example app
Scaffold a new project, or clone an example to start from something that runs:
# scaffold a fresh repo
pfy init
# or clone an example from the monorepo
git clone https://github.com/ProductifyFW/thesis
cd thesis/be-integrations/packages/node-express-example # backend example
# (a Vue 3 frontend example lives under fe-integrations/)Run pfy dev seed from your app's repo root so the generated files land beside its code.
3. Bare loop: the Vite shim
For pure frontend work, the @productifyfw/dev Vite plugin injects the same window.__PRODUCTIFY__ object the real proxy would, reading the dev.productify.json from step 1. No containers required.
// vite.config.ts
import { defineConfig } from "vite";
import { productifyDevShim } from "@productifyfw/dev";
export default defineConfig({
plugins: [
productifyDevShim({ configFile: "dev.productify.json" }),
],
});npm run devYour SPA now sees a fully-populated window.__PRODUCTIFY__ (user, tenant, application, logout_url) exactly as in production. The shim hot-reloads when dev.productify.json changes and no-ops in production builds.
4. Bare loop: the header contract via pfy dev proxy
To exercise your backend against the real header contract without Pocket ID, put the header-injecting dev proxy in front of it:
pfy dev proxy --upstream localhost:8080 --port 9000It forwards to your app and injects the standard identity headers (X-User-ID, X-User-Email, X-User-Name, X-User-Username, X-Tenant-ID, X-Tenant-Name, X-Auth-Type: oauth, and X-Proxy-Auth when you pass --proxy-secret) from dev.productify.json. It binds loopback only (--bind refuses any other address) and performs no authentication; the startup banner says so.
Switch identity or tenant instantly, no cookies, no OIDC:
curl http://127.0.0.1:9000/_dev/users
curl -X POST http://127.0.0.1:9000/_dev/switch -d '{"user":"dev@local.test"}'Your backend now sees the same headers the real proxy sends, without Pocket ID.
5. Full-stack loop: real login through the proxy
Now the real thing. The full stack from step 1 runs the production proxy build and Pocket ID. The generated .pfy/dev/Caddyfile.dev serves two hostnames:
| Hostname | Serves |
|---|---|
http://pocketid.localhost | Pocket ID (admin UI and OIDC endpoints) |
http://app.localhost | The Manager, behind the Pocket ID login portal |
Your own app has no vhost in that file yet. Add one to .pfy/dev/Caddyfile.dev (the file is generated once and your edits survive pfy dev up), modelled on the per-application blocks in the Caddyfile reference, and restart the proxy with docker compose -p pfy-dev restart proxy. Reaching your app through that hostname gives you a real Pocket ID login and the proxy-injected identity headers.
*.localhost names resolve to 127.0.0.1 on most systems without an /etc/hosts entry.
Create the Pocket ID dev user
pfy dev seed ends with a reminder to create the Pocket ID dev user (dev@local.test) for the full-stack login. The CLI does not create Pocket ID accounts; the Manager's Pocket ID integration is read-only (see Session policy). Create the user once in the Pocket ID admin UI (http://pocketid.localhost), using the same email you passed to pfy dev seed (--dev-user-email, default dev@local.test). Pocket ID handles passkey and email-code onboarding.
Then sign in through the app's hostname in the browser and complete the OIDC flow.
That browser login is a different Manager user from the one pfy dev seed ran as. Seeding authenticates as the stack's trusted proxy under a dev identity derived from --dev-user-email; the OIDC login arrives with Pocket ID's own subject, so the Manager keys it to a separate user that is a member of nothing. It sees an empty project list until you add it to the seeded project (Manager UI, the project's Members page, or pfy once you are logged in). Signing out uses the standardized logout contract, window.__PRODUCTIFY__.logout_url, which clears both the portal session and the tenant cookie.
Driving the Manager from the CLI
To operate the Manager from the CLI in the full-stack loop, log in through the proxy's Manager hostname and confirm:
pfy login --server http://app.localhost --web # browser SSO (device flow); or --token / --token-stdin
pfy whoami # confirms the manager URL and token work--server must be the proxied entrypoint (http://app.localhost), not the Manager's own port.
6. Fire your first trigger
The Manager's executor evaluates triggers every PFY_CRON_TRIGGER_CHECK_INTERVAL (default 30s). The dev stack runs the Manager with PFY_RUN_MODE=both, so the executor is already running. Create a trigger for your seeded application in the Manager UI (or via GraphQL; see the API Reference), point it at an endpoint on your app, and watch the executor call it within the check interval. See Triggers & Automation for authoring triggers.
That is the full path: platform up, app wired, both loops exercised, a trigger firing on your machine.
Tear down
pfy dev down # stop the stack (keep data)
pfy dev down --volumes # stop and destroy the local databaseTroubleshooting
host port 5432 is already in use(or another port):pfy dev upchecks every port the stack publishes before starting. Stop the other service or free the port; the message names it. Your own runningpfy-devstack never raises this: the check is skipped when it is up, so re-runningpfy dev upconverges it rather than reporting a conflict against itself.- The Manager API never became ready under
--seed: the stack is left running and the message names the URL that did not answer. Checkpfy dev statusand the Manager's container logs, then runpfy dev seedonce it is up. - Seeding cannot reach the Manager: it resolves the target from
.pfy/dev/compose.dev.yml, so run it from the directorypfy dev upwrote that file into, or pass--manager. IfPFY_MANAGER_URLis set in your shell it wins over the dev stack; unset it. - Check health:
pfy dev statusprobes the Manager (:8081/healthz), Pocket ID (:1411/healthz) and the proxy (:2112/readyz) and reports whether the seeded files exist.
Where to go next
- Configuration Reference: every environment variable, and the minimum secure production config.
- Docker Compose Deployment: the self-hosted install bundle, when you outgrow the dev stack.
- Session Policy & Logout: the logout contract and session lifetime.
- Generated Deployment Artifacts: how real deployments render.