Skip to content

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_SECRET set. 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.yml starts 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:

bash
docker pull ghcr.io/productifyfw/proxy:1.1.0

To build it yourself instead, from the proxy repository root:

bash
cd proxy
docker build -t ghcr.io/productifyfw/proxy:1.1.0 .

To build the binary directly instead:

bash
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.

nginx
{
  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 respond tells Caddy where caddy-security's authenticate directive sits in the handler order. Without it the Caddyfile does not load.
  • admin binds Caddy's admin API to loopback. The Manager pushes rendered configuration to this endpoint; set PFY_ADMIN_LISTEN to an internal address the Manager can reach, and never expose it publicly. Do not use admin off if you want the Manager to manage the proxy.
  • delay_start, retry_attempts, retry_interval make 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 portal is the login UI served under /auth/*. token lifetime 3600 is the absolute session lifetime in seconds; there is no separate idle timeout. cookie insecure on allows the session cookie over plain HTTP; remove it when you serve HTTPS.
  • transform user ... action add role user gives every logged-in user the role user. 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 policy is what productify_protect ... with pocketpolicy enforces. set auth url is where unauthenticated browsers are sent. inject headers with claims adds X-Token-Subject, X-Token-User-Email, X-Token-User-Name and X-Token-User-Roles for the Productify modules to read.
  • productify connects to the Manager. token is the shared proxy secret, sent as X-Proxy-Auth; it must equal the Manager's PFY_PROXY_SECRET. The proxy refuses to start when it is empty.
  • The app.localhost block protects one application. route @auth serves the login flow; route /* runs the full protection chain and forwards to your backend. vars app_id is the application's ID in the Manager.

The Caddyfile Reference documents every option.

3. Start the proxy ​

With docker run ​

bash
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.0

Port 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:

bash
cd proxy
cp .env.example .env      # fill in PFY_MANAGER_TOKEN and PFY_OAUTH_CLIENT_SECRET
docker compose up --build

Compose 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:

yaml
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:

bash
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 serve

An anonymous request to the application must be redirected to the login portal:

bash
curl -i -H 'Host: app.localhost' http://localhost/
# HTTP/1.1 302 Found
# Location: /auth/oauth2/generic?redirect_url=http%3A%2F%2Fapp.localhost%2F

Metrics for the application appear after the first request:

bash
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.

nginx
{
  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".

SymptomCause and fix
failed to fetch metadata for OAuth 2.0 authorization server and Caddy exitsThe 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 setPFY_MANAGER_TOKEN is not set in the container's environment.
productify: manager URL must use http or https schememanager in the productify block is not a full URL.
/readyz returns 503 not ready: manager unreachableThe 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 unavailableThe 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 headerPFY_MANAGER_TOKEN on the proxy and PFY_PROXY_SECRET on the Manager differ.
Login succeeds but every page is 403The 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_versionThe Manager renders for a newer proxy plugin. Upgrade the proxy image. The proxy keeps serving its last good config.
Certificate errors on a public hostDNS does not point at the proxy, email is missing, or ports 80/443 are not reachable from the internet.

Next steps ​