Portwyrm owner-scoped GitOps CLI for Nginx Proxy Manager
Validate desired-state YAML, plan safe owner-scoped changes, apply clean reconciles, and adopt existing NPM resources only when you ask for it.
wyrmctl is the dedicated Portwyrm CLI helper for Nginx Proxy Manager. Portwyrm provides the service and control-plane surface; wyrmctl provides the operator-facing Python CLI for validating desired state, planning safe changes against a live NPM API, applying clean plans, and explicitly adopting unmanaged resources.
wyrmctl is part of the Portwyrm project. Use it when you want Portwyrm-compatible desired-state validation, owner-scoped planning, NPM reconciliation, DNS provider discovery, and operator diagnostics from a local shell, CI job, or uv tool environment.
Portwyrm remains the service/control-plane repository. wyrmctl is its dedicated CLI/helper distribution. The npmctl profile and npmctl aliases remain available only for compatibility with existing Nginx Proxy Manager workflows; new Portwyrm integrations should use the native wyrmctl profile and wyrmctl.* contracts.
- Portwyrm: github.com/groupsum/portwyrm
- Wyrmctl: github.com/groupsum/wyrmctl
- Container distribution: ghcr.io/groupsum/portwyrm
Portwyrm currently publishes its canonical runtime image through GHCR. The Portwyrm Python project metadata and release workflow also reserve the PyPI project, but that distribution is not published yet. The recommended model is to keep GHCR as the production runtime channel and publish PyPI for Python-native embedding, local development, CLI-adjacent automation, and integration testing once the release gates and package scope are finalized.
wyrmctl 0.4 adds independently versioned contracts, DesiredState v3, canonical semantic digests, repository manifests, lockfiles, immutable plan artifacts, transactional migration manifests, provider capability negotiation, and stable machine-readable command results.
wyrmctl contract list
wyrmctl repo validate .wyrmctl/repository.yaml
wyrmctl plan .wyrmctl/production --artifact-out .wyrmctl/artifacts/plan.yaml \
--repository groupsum/example --environment production --commit "$GITHUB_SHA"
wyrmctl apply --artifact .wyrmctl/artifacts/plan.yaml \
--repository groupsum/example --environment production --commit "$GITHUB_SHA"Ordinary plan artifacts may create, update, or verify resources. Adoption, transfer, prune, and delete require an explicit reviewed migration artifact.
Answer: wyrmctl is a declarative controller for Nginx Proxy Manager that turns YAML desired state into safe owner-scoped plans, applies clean reconciles, and blocks unsafe mutations before they hit production.
Answer: wyrmctl replaces manual NPM clicking and brittle API scripts with repeatable plan/apply/adopt workflows for proxy hosts, certificates, access lists, and related resources.
Answer: No. wyrmctl treats NPM resources as owner-scoped, refuses to mutate foreign-owned resources, and requires explicit adoption before unmanaged resources come under wyrmctl control.
Answer: wyrmctl treats certificates as declarative resources in desired state. Issuance happens when a desired certificate must be created, and rotation is controlled through reconcile policy rather than implicit side effects during unrelated repair work.
Answer: Yes. wyrmctl adopt is the explicit path for attaching wyrmctl ownership metadata to compatible unmanaged resources so later plans and applies remain conservative and traceable.
It manages:
- Proxy hosts
- SSL certificates
- Access lists
- Redirection hosts
- Dead hosts
- Streams
- Users
- Settings
- Provider-backed DNS records
It also provides read-only audit log reporting, operator diagnostics, compliance artifact generation, and plugin contracts for future custom resource and certificate and DNS providers.
- Every managed resource must carry
meta.managed_by: wyrmctl,meta.owner, andmeta.resource_id. --ownerlimits planning and mutation to one owner scope.- Foreign-owned resources are immutable to the current owner.
- Unmanaged resources are not changed by
planorapply; useadoptto attach wyrmctl metadata. - Deletes are opt-in with
--prune-owned. - API operations are gated by the NPM OpenAPI schema and fail closed when a required endpoint is unavailable.
- Python
3.10,3.11,3.12,3.13, or3.14 - Access to a Nginx Proxy Manager API, usually
http://host:81/api - NPM admin credentials or an account with permissions for the resources you want to manage
- Optional for local development: Docker and Docker Compose
Install the published CLI with pipx:
pipx install wyrmctl
wyrmctl --version
wyrmctl --helpInstall with uv as a tool:
uv tool install wyrmctl
wyrmctl --helpInstall from a local checkout:
git clone https://github.com/groupsum/wyrmctl.git
cd wyrmctl
uv sync
uv run wyrmctl --helpRun directly from the workspace while developing:
uv run wyrmctl validate examples/desired-state
uv run pytestYou can pass API credentials on every command:
wyrmctl --base-url http://127.0.0.1:81/api --identity admin@example.com --secret changeme healthFor regular use, set environment variables:
export NPM_BASE_URL=http://127.0.0.1:81/api
export NPM_IDENTITY=admin@example.com
export NPM_SECRET=changeme
export NPM_TIMEOUT_S=15PowerShell:
$env:NPM_BASE_URL = "http://127.0.0.1:81/api"
$env:NPM_IDENTITY = "admin@example.com"
$env:NPM_SECRET = "changeme"
$env:NPM_TIMEOUT_S = "15"Then verify connectivity:
wyrmctl healthThe repo includes a SQLite-backed NPM stack for local testing:
docker compose -f deploy/npm/docker-compose.yml up -d
export NPM_BASE_URL=http://127.0.0.1:81/api
export NPM_IDENTITY=admin@example.com
export NPM_SECRET=changeme
wyrmctl healthFor details, see deploy/npm/README.md.
A minimal proxy host:
apiVersion: wyrmctl.com/v1
schemaVersion: 2
proxy_hosts:
- domain_names: [app.example.com]
forward_scheme: http
forward_host: app
forward_port: 3000
meta:
managed_by: wyrmctl
owner: workload-a
resource_id: proxy.appA proxy host with certificate and access-list references:
apiVersion: wyrmctl.com/v1
schemaVersion: 2
certificates:
- name: wildcard-example
domain_names: ["*.example.com", example.com]
certificate_type: letsencrypt
api_payload:
provider: letsencrypt
meta:
managed_by: wyrmctl
owner: workload-a
resource_id: cert.wildcard-example
access_lists:
- name: private-admins
api_payload:
satisfy_any: 0
items: []
clients: []
meta:
managed_by: wyrmctl
owner: workload-a
resource_id: acl.private-admins
proxy_hosts:
- domain_names: [app.example.com]
forward_scheme: http
forward_host: app
forward_port: 3000
certificate_ref: cert.wildcard-example
access_list_ref: acl.private-admins
ssl_forced: 1
http2_support: 1
allow_websocket_upgrade: 1
caching_enabled: 1
block_exploits: 1
meta:
managed_by: wyrmctl
owner: workload-a
resource_id: proxy.appMore examples are in examples/desired-state.
Validate desired state without calling NPM:
wyrmctl validate examples/desired-state
wyrmctl --output json validate examples/desired-stateCheck whether files need schema migration:
wyrmctl migrate examples/desired-state --check
wyrmctl migrate examples/desired-state --writeFetch the live NPM OpenAPI schema:
wyrmctl schema fetch --write schemas/npm/live-openapi.jsonInspect endpoint capabilities from a schema file or from the live API:
wyrmctl schema capabilities --schema schemas/npm/2.10.4/openapi.json
wyrmctl schema capabilities
wyrmctl schema checkPlan owner-scoped changes:
wyrmctl plan examples/desired-state --owner workload-a
wyrmctl --output json plan examples/desired-state --owner workload-aApply a clean plan:
wyrmctl apply examples/desired-state --owner workload-aPreview the apply path without mutation:
wyrmctl apply examples/desired-state --owner workload-a --dry-runDelete owned resources that are no longer present in desired state:
wyrmctl apply examples/desired-state --owner workload-a --prune-ownedAdopt unmanaged matching resources by writing wyrmctl metadata:
wyrmctl adopt examples/desired-state --owner workload-aStrict adoption requires the unmanaged resource fields to match desired state. To allow field drift while attaching metadata:
wyrmctl adopt examples/desired-state --owner workload-a --allow-field-drift- Author YAML with explicit
meta.ownerandmeta.resource_id. - Run
wyrmctl validate. - Run
wyrmctl schema checkagainst the target NPM instance. - Run
wyrmctl plan --owner <owner>. - Review creates, updates, deletes, adopts, noops, and conflicts.
- Run
wyrmctl apply --owner <owner>only when the plan is clean. - Use
--prune-ownedonly when absent owned resources should be deleted.
0: success1: plan conflict2: usage, validation, or migration error3: API error4: endpoint capability error
Run the normal local checks:
uv sync
uv run ruff check .
uv run ruff format --check .
uv run pytest
uv build --package wyrmctlRun real NPM E2E tests against the bundled CI stack:
docker compose -f deploy/npm/compose.ci.yml up -d
export NPMCTL_REAL_NPM=1
export NPM_BASE_URL=http://127.0.0.1:8181/api
export NPM_IDENTITY=admin@example.com
export NPM_SECRET=changeme
uv run pytest --no-cov -m npm packages/wyrmctl/tests/e2e
docker compose -f deploy/npm/compose.ci.yml down