Files
shankar0123 0729ee46e0 chore: sweep github.com/shankar0123/certctl URL refs to certctl-io/certctl
Post-transfer cosmetic + release-critical URL refresh after moving the
repo from github.com/shankar0123/certctl to github.com/certctl-io/certctl
(2026-05-03). GitHub HTTP redirects continue to forward old URLs forever,
so existing operators are not broken — but aligns the canonical
references with the new owner so:

- procurement engineers / contributors browsing the docs see the right
  URL on first read
- operators copying the agent install one-liner hit the new path
  directly without going through a redirect
- the Helm chart's default image repository points at the canonical org
  registry path
- the OnboardingWizard rendered to first-run UI users shows the new
  URL in the install snippets and doc anchor links
- the GitHub Actions release workflow pushes container images to
  ghcr.io/certctl-io/certctl-{server,agent} (was: shankar0123)
- the release-notes Markdown body in release.yml — which gets stamped
  into every future release page — references the post-transfer
  cert-identity (cosign keyless signing now uses the certctl-io
  workflow URL) and the post-transfer SLSA provenance source-uri.
  Without this, every cosign verify / slsa-verifier command on a
  v2.1.0+ release would fail because the cert-identity-regexp would
  not match the signing identity GitHub Actions OIDC issues post-
  transfer. Old releases (v2.0.67 and earlier) keep their immutable
  release-notes pointing at the shankar0123 path and remain
  verifiable via their own published instructions.

Customer impact:
- Operators on ghcr.io/shankar0123/certctl-{server,agent}:latest
  silently freeze on whatever tag was current at transfer time. They
  get no errors; they just stop receiving updates. The next release
  notes need a one-line callout (Phase 3.1 of cowork/transfer-
  certctl-to-org.md) telling them to update their image path to
  ghcr.io/certctl-io/certctl-{server,agent}.
- All other URLs (git clone, install one-liner, raw.githubusercontent
  URLs, browser links, GitHub API) continue to resolve via permanent
  HTTP redirects. The sweep is cosmetic for those.

Files swept (30 total):
  .github/workflows/release.yml — IMAGE_NAMESPACE, source-uri,
    cosign cert-identity-regexp, IMAGE= snippet (5 refs total).
  CHANGELOG.md, README.md — anchor links, badges, install one-liner,
    cosign verify snippets in operator-facing sections.
  api/openapi.yaml — info / externalDocs URLs.
  install-agent.sh — GITHUB_REPO const + systemd unit Documentation=
    field.
  deploy/ENVIRONMENTS.md, deploy/helm/{CHART_SUMMARY,INDEX,
    INSTALLATION,README}.md, deploy/helm/certctl/{Chart.yaml,
    README.md,values.yaml}, deploy/helm/examples/values-*.yaml —
    chart docs + image repository defaults across dev / prod-ha
    overrides.
  docs/{certctl-for-cert-manager-users,connector-iis,connectors,
    migrate-from-acmesh,migrate-from-certbot,quickstart,test-env,
    why-certctl}.md — operator-facing doc URLs.
  examples/{acme-nginx,acme-wildcard-dns01,multi-issuer,
    private-ca-traefik,step-ca-haproxy}/docker-compose.yml +
    examples/step-ca-haproxy/step-ca-haproxy.md — example image:
    paths and accompanying narrative.
  web/src/pages/OnboardingWizard.tsx — first-run-UI URL refs (curl
    install one-liners, agent docker image path, doc anchor links).

Files intentionally NOT swept (Choice A from cowork/transfer-certctl-
to-org.md):
  go.mod, go.sum — module declaration stays github.com/shankar0123/
    certctl. Existing imports compile because Go uses the path
    declared in go.mod, not the URL it was fetched from. Internal-
    only project; no external Go consumers; rename will land as a
    mechanical sed when one materializes.
  ~250 *.go files — every import remains github.com/shankar0123/
    certctl/internal/...
  deploy/test/f5-mock-icontrol/go.mod — separate test sub-module;
    same Choice A logic; module path stays.

Files intentionally NOT swept (other reasons):
  README.md lines 244-245 — Scarf-pixel docker-pull commands.
    shankar0123.docker.scarf.sh/... is a Scarf-account hostname
    (per-user, not per-repo) and the pixel keeps tracking pulls
    against the operator's personal Scarf account. Migrating to a
    certctl-io Scarf account is a separate decision (create org
    Scarf account → re-create package → update README).
  deploy/test/f5-mock-icontrol/f5-mock-icontrol — checked-in
    compiled binary with shankar0123/certctl baked into Go build
    info via the sub-module path. Out of scope for a URL sweep;
    will refresh on the next `make test-integration` rebuild.

Verification:
  gofmt: clean (no .go files touched).
  go vet ./...: clean (verified at this SHA in 1.3 of the transfer
    checklist; no .go changes since).
  go build ./...: clean (same).
  go test -short on representative packages: green (same).
  Diff shape: 30 files, 74 insertions / 74 deletions, net-zero size,
    pure URL substitution.
2026-05-03 23:39:50 +00:00

10 KiB

step-ca + HAProxy Example

This example demonstrates certctl managing certificates issued by Smallstep step-ca and deploying them to HAProxy.

Operational notes shared by every example (postgres password rotation trap, TLS provisioning, teardown semantics) live in ../README.md. Read it first if you plan to change DB_PASSWORD after the initial docker compose up — the postgres volume binds the password on first boot only.

Scenario

You're a Smallstep user running step-ca as your internal PKI. You have HAProxy load balancers that need certificates. This setup:

  1. step-ca issues certificates (via JWK provisioner, no challenge solving)
  2. certctl manages the certificate lifecycle (renewal policies, deployment, audit)
  3. HAProxy serves HTTPS with certificates managed by certctl

This is the natural choice if you're already invested in step-ca and want to consolidate certificate lifecycle management without learning Let's Encrypt, DNS-01 challenges, or external integrations.

What's Included

Service Image Purpose
step-ca smallstep/step-ca:latest Private internal CA
certctl-server ghcr.io/certctl-io/certctl-server:latest Certificate management control plane
certctl-agent ghcr.io/certctl-io/certctl-agent:latest Agent running on HAProxy server
haproxy haproxy:2.9-alpine Reverse proxy / load balancer
postgres postgres:16-alpine certctl audit trail + config storage

Quick Start

Prerequisites

  • Docker and Docker Compose
  • Curl (to interact with APIs)

1. Start Everything

docker compose up -d

This will:

  • Initialize step-ca with a self-signed root CA
  • Create a JWK provisioner named certctl (pre-configured credentials)
  • Start certctl-server (connected to step-ca)
  • Start the certctl-agent (ready to deploy certs to HAProxy)
  • Start HAProxy with a placeholder config

Monitor logs:

docker compose logs -f certctl-server

TLS Security

certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing https://localhost:8443, you can either:

  • Use curl --cacert ./deploy/test/certs/ca.crt ... to pin the CA certificate
  • Use curl -k ... for quick smoke tests (never in production)
  • Import the CA at ./deploy/test/certs/ca.crt into your OS trust store for browser visits

Wait for all services to reach healthy state:

docker compose ps

Expected output:

NAME                              STATUS
certctl-postgres-...              healthy
certctl-server-...                healthy
step-ca-...                       healthy
certctl-agent-...                 running
certctl-haproxy-...               healthy

2. Access certctl Dashboard

Open your browser to:

https://localhost:8443

You should see an empty dashboard. This is expected — no certificates issued yet.

3. Create a Certificate Profile

This defines what certificates certctl can issue (key algorithm, max TTL, allowed names).

curl -X POST https://localhost:8443/api/v1/profiles \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "internal-web",
    "key_type": "rsa-2048",
    "max_ttl_days": 90,
    "description": "Internal web services"
  }'

4. Create an HAProxy Deployment Target

This tells certctl where to deploy certificates on the HAProxy server.

curl -X POST https://localhost:8443/api/v1/targets \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "haproxy-01",
    "type": "haproxy",
    "enabled": true,
    "config": {
      "pem_path": "/etc/haproxy/ssl/cert.pem",
      "reload_command": "systemctl reload haproxy",
      "validate_command": "haproxy -c -f /etc/haproxy/haproxy.cfg"
    }
  }'

Note: In the Docker Compose environment, reload command can be kill -HUP $(pidof haproxy) instead of systemctl reload haproxy.

5. Create a Renewal Policy

This ties a certificate profile to a deployment target and sets renewal thresholds.

curl -X POST https://localhost:8443/api/v1/renewal-policies \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "haproxy-internal-web",
    "profile_id": "<profile_id_from_step_3>",
    "issuer_id": "iss-stepca",
    "enabled": true,
    "renewal_days_before_expiry": 30,
    "alert_thresholds_days": [30, 14, 7, 0]
  }'

Get the issuer ID:

curl https://localhost:8443/api/v1/issuers | jq '.'

You should see iss-stepca in the list.

6. Issue a Certificate

Request a certificate via the API. The server will sign it via step-ca.

curl -X POST https://localhost:8443/api/v1/certificates \
  -H 'Content-Type: application/json' \
  -d '{
    "common_name": "api.internal.example.com",
    "sans": ["api.internal.example.com", "api.staging.example.com"],
    "issuer_id": "iss-stepca",
    "profile_id": "<profile_id_from_step_3>"
  }'

7. Deploy to HAProxy

Get the certificate ID and trigger deployment:

curl -X POST https://localhost:8443/api/v1/certificates/<cert_id>/deploy \
  -H 'Content-Type: application/json' \
  -d '{
    "target_id": "<target_id_from_step_4>"
  }'

The agent will:

  1. Fetch the deployment job
  2. Generate a combined PEM (cert + chain + key) locally
  3. Write it to /etc/haproxy/ssl/cert.pem on HAProxy
  4. Reload HAProxy
  5. Report status back to certctl

8. Verify in Dashboard

Refresh https://localhost:8443 and you should see:

  • 1 certificate (status: Active, expiry in 90 days)
  • 1 deployment job (status: Completed)
  • 1 agent (heartbeat: recent)

Configuration Details

step-ca Integration

step-ca is configured with:

  • Root CA Name: certctl-demo-ca
  • Provisioner: certctl (JWK type)
  • Default Password: certctl-provisioner-demo (override with STEP_CA_PROVISIONER_PASSWORD)

To inspect step-ca:

docker compose exec step-ca step ca provisioner list
docker compose exec step-ca step ca health --insecure

HAProxy Combined PEM Format

HAProxy requires a single file with certificate, chain, and key concatenated:

-----BEGIN CERTIFICATE-----
[leaf certificate]
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
[intermediate CA]
-----END CERTIFICATE-----
-----BEGIN RSA PRIVATE KEY-----
[private key]
-----END RSA PRIVATE KEY-----

The agent automatically constructs this file from the issued certificate and step-ca-provided chain.

Security: The combined PEM is written with 0600 permissions (owner-readable only) because it contains the private key.

Environment Variables

Customize behavior with:

Variable Default Purpose
DB_PASSWORD certctl-dev-password PostgreSQL password
STEP_CA_PASSWORD stepca-demo-password step-ca root key password
STEP_CA_PROVISIONER_PASSWORD certctl-provisioner-demo certctl JWK provisioner password
AGENT_API_KEY agent-demo-key Agent authentication token
SERVER_PORT 8443 certctl server external port

Example:

STEP_CA_PASSWORD=myca-password AGENT_API_KEY=secret-key docker compose up -d

Integrating with an Existing step-ca Instance

If you already run step-ca elsewhere (not in this Compose file):

  1. Extract the root certificate from your step-ca:

    step ca root /tmp/step-ca-root.crt --ca-url https://ca.internal:9000 --insecure
    
  2. Create or retrieve the certctl JWK provisioner key:

    step ca provisioner list --ca-url https://ca.internal:9000 --insecure
    step ca provisioner describe certctl --ca-url https://ca.internal:9000 --insecure
    
  3. Update docker-compose.yml:

    certctl-server:
      environment:
        CERTCTL_STEPCA_URL: https://ca.internal:9000
        CERTCTL_STEPCA_ROOT_CERT_PATH: /etc/certctl/step-ca-root.crt
        CERTCTL_STEPCA_PROVISIONER_NAME: certctl
        CERTCTL_STEPCA_PROVISIONER_KEY_PATH: /etc/certctl/step-ca-provisioner.json
        CERTCTL_STEPCA_PROVISIONER_PASSWORD: <your-password>
    
  4. Mount the cert and key:

    volumes:
      - /path/to/step-ca-root.crt:/etc/certctl/step-ca-root.crt:ro
      - /path/to/provisioner.json:/etc/certctl/step-ca-provisioner.json:ro
    

Cleanup

docker compose down -v

This removes all containers and volumes (step-ca config, certificates, database).

Next Steps

Production Deployment

  • Replace image tags (latest → specific version)
  • Use real TLS certificates for step-ca (self-signed is fine internally, but use proper roots for verification)
  • Configure persistent storage for step-ca keys (HSM or encrypted filesystem)
  • Set CERTCTL_AUTH_TYPE: api-key and rotate API keys regularly
  • Enable audit trail export for compliance
  • Configure renewal alerts (Slack, email, PagerDuty)
  • Run agents on separate machines (not in Compose)

Advanced Features

  • Multiple HAProxy instances: Create additional targets and agents
  • Policy-based renewal: Set different renewal windows per environment (staging vs. production)
  • Approval workflows: Require manual approval before deploying to production
  • Discovery: Scan existing HAProxy certs and bring them under management
  • Network scanning: Discover TLS endpoints in your network and inventory them

Troubleshooting

step-ca fails to initialize

Check logs:

docker compose logs step-ca

Common issues:

  • Permissions on /home/step/step-ca volume
  • Port 9000 already in use

Agent can't reach server

Verify network:

docker compose exec certctl-agent curl http://certctl-server:8443/health

HAProxy config validation fails

Check HAProxy config syntax:

docker compose exec haproxy haproxy -c -f /etc/haproxy/haproxy.cfg

Deployment job stays in "Running" state

Check agent logs:

docker compose logs certctl-agent

Likely causes:

  • Agent can't write to /etc/haproxy/ssl/cert.pem (permissions)
  • Reload command is misconfigured
  • HAProxy container is not accessible

Documentation

Support

For issues or questions:

  1. Check the troubleshooting guide
  2. Review service logs: docker compose logs <service>
  3. Open an issue on GitHub