Quick Start
Run a Productify Proxy on your machine, in front of one application, with login through an OpenID Connect identity provider: get the custom Caddy image, write a small Caddyfile, start the container and check that requests are redirected to the login page.
Prerequisites
- Docker with Docker Compose, or Go 1.26 to build the binary without Docker.
- A running Productify Manager that the proxy can reach, started with
PFY_PROXY_SECRETset. You will give the proxy the same value. - An OAuth2/OIDC identity provider with a client registered for the proxy. The repository's
docker-compose.ymlstarts Pocket ID for you. You need the client ID and client secret. - The application ID of the application you want to protect, as shown in the Manager.
1. Get the custom Caddy
The proxy is Caddy plus two plugin sets, so you cannot use the stock Caddy image. The published image is public:
docker pull ghcr.io/productifyfw/proxy:1.1.0To build it yourself instead, from the proxy repository root:
cd proxy
docker build -t ghcr.io/productifyfw/proxy:1.1.0 .To build the binary directly instead:
cd proxy/caddy
go build -o caddy .
./caddy list-modules | grep productify # expect http.handlers.productify_* and "productify"2. Write a Caddyfile
Save this as Caddyfile. Replace YOUR_CLIENT_ID and YOUR_APPLICATION_ID; the two secrets come from environment variables.
{
order authenticate before respond
admin {$PFY_ADMIN_LISTEN:127.0.0.1:2019}
security {
oauth identity provider generic {
delay_start 1
retry_attempts 3
retry_interval 2
realm generic
driver generic
client_id YOUR_CLIENT_ID
client_secret {$PFY_OAUTH_CLIENT_SECRET}
scopes openid email profile
base_auth_url http://pocketid.localhost
metadata_url http://pocketid.localhost/.well-known/openid-configuration
}
authentication portal pocketportal {
crypto default token lifetime 3600
enable identity provider generic
cookie insecure on
transform user {
match realm generic
action add role user
}
}
authorization policy pocketpolicy {
set auth url /auth/oauth2/generic
allow roles user
validate bearer header
inject headers with claims
enable strip token
}
}
productify {
manager http://manager:8080
token {env.PFY_MANAGER_TOKEN}
enable_access_log
enable_metrics
}
}
http://pocketid.localhost {
reverse_proxy pocketid:1411
}
http://app.localhost {
vars app_id YOUR_APPLICATION_ID
@auth path /auth/*
route @auth {
productify_sanitize
productify_before_auth with {vars.app_id}
authenticate with pocketportal
}
route /* {
productify_protect {vars.app_id} with pocketpolicy
reverse_proxy backend:8080
}
}What each part does:
order authenticate before respondtells Caddy where caddy-security'sauthenticatedirective sits in the handler order. Without it the Caddyfile does not load.adminbinds Caddy's admin API to loopback. The Manager pushes rendered configuration to this endpoint; setPFY_ADMIN_LISTENto an internal address the Manager can reach, and never expose it publicly. Do not useadmin offif you want the Manager to manage the proxy.delay_start,retry_attempts,retry_intervalmake caddy-security fetch the identity provider's metadata in the background with retries. Without them Caddy fetches the metadata while loading the config and exits if the identity provider is not reachable yet, which is the normal case when both start at the same time.authentication portalis the login UI served under/auth/*.token lifetime 3600is the absolute session lifetime in seconds; there is no separate idle timeout.cookie insecure onallows the session cookie over plain HTTP; remove it when you serve HTTPS.transform user ... action add role usergives every logged-in user the roleuser. Pocket ID issues no role claim by default, and a user with no role matches no policy, so without this line nobody is ever authorised.authorization policyis whatproductify_protect ... with pocketpolicyenforces.set auth urlis where unauthenticated browsers are sent.inject headers with claimsaddsX-Token-Subject,X-Token-User-Email,X-Token-User-NameandX-Token-User-Rolesfor the Productify modules to read.productifyconnects to the Manager.tokenis the shared proxy secret, sent asX-Proxy-Auth; it must equal the Manager'sPFY_PROXY_SECRET. The proxy refuses to start when it is empty.- The
app.localhostblock protects one application.route @authserves the login flow;route /*runs the full protection chain and forwards to your backend.vars app_idis the application's ID in the Manager.
The Caddyfile Reference documents every option.
3. Start the proxy
With docker run
export PFY_MANAGER_TOKEN='<same value as the Manager PFY_PROXY_SECRET>'
export PFY_OAUTH_CLIENT_SECRET='<OIDC client secret>'
docker run -d \
--name productify-proxy \
-p 80:80 \
-p 443:443 \
-p 2112:2112 \
-e PFY_MANAGER_TOKEN \
-e PFY_OAUTH_CLIENT_SECRET \
-v ./Caddyfile:/etc/caddy/Caddyfile \
-v caddy_data:/data \
-v caddy_config:/config \
ghcr.io/productifyfw/proxy:1.1.0Port 2112 serves metrics and health probes; expose it only on networks you trust. The admin API on 2019 is bound to loopback inside the container and is not published.
With the repository's Docker Compose
The proxy repository ships a docker-compose.yml that starts Pocket ID and the proxy together, using the repository's Caddyfile:
cd proxy
cp .env.example .env # fill in PFY_MANAGER_TOKEN and PFY_OAUTH_CLIENT_SECRET
docker compose up --buildCompose refuses to start and names the missing variable if either secret is unset. It publishes ports 80, 443 and 2112 for the proxy and 1411 for Pocket ID.
Your own Compose file
If you write your own Compose file, pass the two secrets through and keep the admin port unpublished:
services:
proxy:
build: ./proxy
ports:
- "80:80"
- "443:443"
- "2112:2112" # metrics, /healthz, /readyz; internal networks only
environment:
PFY_MANAGER_TOKEN: ${PFY_MANAGER_TOKEN:?set PFY_MANAGER_TOKEN (must match the Manager PFY_PROXY_SECRET)}
PFY_OAUTH_CLIENT_SECRET: ${PFY_OAUTH_CLIENT_SECRET:?set PFY_OAUTH_CLIENT_SECRET}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
depends_on:
- manager
manager:
image: ghcr.io/productifyfw/manager:2.0.0
ports:
- "8080:8080"
volumes:
- ./config.yml:/app/config.yml:ro # Manager configuration (or use PFY_* env vars)
restart: unless-stopped
volumes:
caddy_data:
caddy_config:4. Check that it works
Liveness and readiness probes are on the metrics port:
curl -i http://localhost:2112/healthz
# HTTP/1.1 200 OK ... ok
curl -i http://localhost:2112/readyz
# HTTP/1.1 200 OK ... ready -> the Manager answered its /health with a 2xx
# HTTP/1.1 503 ... not ready: manager unreachable -> unreachable, or the Manager says it cannot serveAn anonymous request to the application must be redirected to the login portal:
curl -i -H 'Host: app.localhost' http://localhost/
# HTTP/1.1 302 Found
# Location: /auth/oauth2/generic?redirect_url=http%3A%2F%2Fapp.localhost%2FMetrics for the application appear after the first request:
curl -s http://localhost:2112/metrics | grep '^pfy_'Finally, open http://app.localhost in a browser (add it and pocketid.localhost to /etc/hosts if they do not resolve). You are sent to the portal, then to the identity provider, and back to your application with X-User-ID, X-User-Email and X-Tenant-ID headers set on the forwarded request.
Serving HTTPS
For a public hostname, change three things: use the bare domain as the site address so Caddy obtains a certificate automatically, give Caddy an email for the certificate authority, and drop cookie insecure on.
{
email admin@example.com
order authenticate before respond
admin {$PFY_ADMIN_LISTEN:127.0.0.1:2019}
security {
# ... identical to the block above, minus "cookie insecure on",
# with base_auth_url / metadata_url pointing at your https identity provider
}
productify {
manager http://manager:8080
token {env.PFY_MANAGER_TOKEN}
}
}
manager.example.com {
reverse_proxy manager:8080
}
app.example.com {
vars app_id YOUR_APPLICATION_ID
@auth path /auth/*
route @auth {
productify_sanitize
productify_before_auth with {vars.app_id}
authenticate with pocketportal
}
route /* {
productify_protect {vars.app_id} with pocketpolicy
reverse_proxy backend:8080
}
}Ports 80 and 443 must be reachable from the internet for the certificate challenge, and DNS for both names must point at the proxy.
Troubleshooting
Read the container logs first: docker logs productify-proxy. Caddy logs are JSON; the Productify modules log under "logger":"productify".
| Symptom | Cause and fix |
|---|---|
failed to fetch metadata for OAuth 2.0 authorization server and Caddy exits | The identity provider was not reachable while the config loaded. Add delay_start 1, retry_attempts 3, retry_interval 2 to the oauth identity provider block. |
productify: token (proxy shared secret) must be set | PFY_MANAGER_TOKEN is not set in the container's environment. |
productify: manager URL must use http or https scheme | manager in the productify block is not a full URL. |
/readyz returns 503 not ready: manager unreachable | The proxy cannot reach manager over the network, the URL is wrong, or the Manager's own GET /health reports it cannot serve (most often an unmigrated or unreachable database). Curl it with the shared secret for the reason: curl -H "X-Proxy-Auth: $PFY_MANAGER_TOKEN" <manager>/health. |
Bearer requests return 503 Authentication service unavailable | The Manager is down or returned an error. The proxy fails closed on personal tokens and machine users rather than letting the request through. |
Manager logs missing or invalid X-Proxy-Auth header | PFY_MANAGER_TOKEN on the proxy and PFY_PROXY_SECRET on the Manager differ. |
Login succeeds but every page is 403 | The user has no allowed role (check transform user ... add role user), or the Manager does not authorise the user for this application. |
Config pushed by the Manager is rejected with config requires min_proxy_version | The Manager renders for a newer proxy plugin. Upgrade the proxy image. The proxy keeps serving its last good config. |
| Certificate errors on a public host | DNS does not point at the proxy, email is missing, or ports 80/443 are not reachable from the internet. |