Sign inSign up

ridgway90/pemforge

By ridgway90

•Updated 3 months ago

Image
2

1.9K

ridgway90/pemforge repository overview

⁠PEMForge 20260807.573

SSL certificate monitoring and automatic ACME renewal for Windows/IIS environments.

ServicePortPurpose
PEMForge5000SSL certificate monitoring, expiry alerts, CSR/PFX tools, ACME renewal + Windows agent
Agent Gateway5002Rate-limiting proxy for the Windows agent API

⁠Quick start

1. Create your .env file in the same directory as docker-compose.yml:

# Generate SECRET_KEY (run once):
#   python -c "import secrets; print(secrets.token_hex(32))"

PEMFORGE_SECRET_KEY=replace-with-64-hex-chars

2. Deploy:

docker compose -f docker-compose.yml up -d

3. Access:

  • http://localhost:5000⁠ - the setup wizard walks you through creating the admin account (and, optionally, SMTP) on first visit.

Put PEMForge behind a reverse proxy for HTTPS - see below for ready-to-deploy Cloudflare Tunnel, Caddy, and Traefik variants, or bring your own.

⁠docker-compose.yml

Save this as docker-compose.yml alongside your .env file
# PEMForge - unified SSL certificate monitoring + ACME renewal
# PEMForge:    http://<host>:5000  - SSL monitoring + ACME renewal UI
# Agent Gateway: http://<host>:5002  - Rate-limiting proxy for the Windows agent API
#
# PemForge and RenewForge (the two products PEMForge was merged from) are
# retired from this deployment - pemforge now serves everything: the
# certificate-monitoring UI, the ACME renewal UI, and the Windows agent's
# API/download traffic. Their source trees remain in this repo for reference,
# but are no longer built, published, or deployed here.
#
# Both ports are served by the nginx-agent container - pemforge has no
# direct host port binding of its own; nginx is the sole ingress. To put a
# TLS-terminating reverse proxy (nginx, Caddy, etc.) in front, bind
# nginx-agent ports to 127.0.0.1 and proxy from your reverse proxy.
#
# Setup:  copy .env.pub.example → .env, fill in values, then:
#   docker compose -f docker-compose.pub.yml up -d
# Update: docker compose -f docker-compose.pub.yml pull && docker compose -f docker-compose.pub.yml up -d

networks:
  forge-net:
    driver: bridge

volumes:
  pemforge_data:

services:
  pemforge:
    image: ridgway90/pemforge:pemforge
    container_name: pemforge
    restart: unless-stopped
    volumes:
      - pemforge_data:/data
    environment:
      - DB_PATH=/data/pemforge.db
      - PORT=5000
      - TZ=Europe/London
      - SECRET_KEY=${PEMFORGE_SECRET_KEY:-}
      # Optional: enable the token-gated Prometheus /metrics endpoint (blank = disabled)
      - METRICS_TOKEN=${PEMFORGE_METRICS_TOKEN:-}
      - NUM_PROXIES=${NUM_PROXIES:-2}
      # Required for ACME/Let's Encrypt DNS-01 challenge via Cloudflare:
      - CF_Token=${CF_Token:-}
    networks:
      - forge-net
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    deploy:
      resources:
        limits:
          memory: 1024m
          cpus: '2.00'
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/healthz"]
      start_period: 30s
      interval: 30s
      timeout: 5s
      retries: 3

  # ── PEMForge DNS (optional) ────────────────────────────────────────────────
  # Uncomment to enable CNAME-delegation DNS-01 challenges (no per-domain DNS API
  # credentials needed). Requires:
  #   1. An NS record delegating e.g. acme-challenges.yourmsp.com → your server IP
  #   2. An A record for that subdomain pointing to your server
  #   3. Select "PEMForge DNS" as the DNS Provider in Settings
  # See github.com/joohoi/acme-dns for full setup docs.
  #
  # acmedns:
  #   image: joohoi/acme-dns:latest
  #   container_name: acmedns
  #   restart: unless-stopped
  #   ports:
  #     - "53:53/udp"
  #     - "53:53/tcp"
  #     - "127.0.0.1:8080:8080"
  #   volumes:
  #     - ./acmedns:/var/lib/acme-dns
  #   networks:
  #     - forge-net
  #   security_opt:
  #     - no-new-privileges:true
  #   cap_drop:
  #     - ALL
  #   cap_add:
  #     - NET_BIND_SERVICE

  nginx-agent:
    # Sole ingress. Port 5000 -> pemforge UI, 5002 -> agent gateway (rate-limited).
    # Put a TLS-terminating reverse proxy in front and set NUM_PROXIES=2 in .env.
    image: ridgway90/pemforge:nginx-agent
    container_name: nginx-agent
    restart: unless-stopped
    ports:
      - "5000:5000"   # PEMForge UI
      - "5002:5004"   # Agent gateway
    networks:
      - forge-net
    depends_on:
      - pemforge
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    deploy:
      resources:
        limits:
          memory: 128m
          cpus: '0.50'
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5004/"]
      start_period: 45s
      interval: 30s
      timeout: 5s
      retries: 3

⁠Reverse proxy variants

Prefer a specific reverse proxy over exposing PEMForge's own ports directly? Each of these is a complete, self-contained docker-compose.yml replacement - expand the one you want, save it as docker-compose.yml, and deploy the same way as above. Each variant needs the same .env values as the plain compose file, plus the proxy-specific ones noted below it.

Cloudflare Tunnel - no inbound ports to open at all

Add CLOUDFLARE_TUNNEL_TOKEN to .env, then in the Cloudflare dashboard point your tunnel's two public hostnames at http://nginx-agent:5000 (UI) and http://nginx-agent:5004 (agent API).

# PEMForge - public deployment fronted by a Cloudflare Tunnel
#
# Same pemforge + nginx-agent services as docker-compose.pub.yml (keep them in
# sync if you edit one), plus a cloudflared container that connects outbound
# to Cloudflare - no inbound ports need to be opened on this host at all, and
# nginx-agent has no host port binding here since only cloudflared needs to
# reach it, over the internal Docker network.
#
# Setup:
#   1. Cloudflare dashboard -> Zero Trust -> Networks -> Tunnels -> Create a tunnel
#      (choose the "Cloudflare" connector type), copy its token.
#   2. In that tunnel's "Public Hostname" tab, add TWO hostnames pointing at
#      this same tunnel:
#        - your UI domain      -> Service: http://nginx-agent:5000
#        - your agent-API domain -> Service: http://nginx-agent:5004
#      (5004 is nginx-agent's container-internal agent-gateway port - see
#      docker-compose.pub.yml's port mapping comment.)
#   3. Copy .env.pub.example -> .env, fill in PEMFORGE_SECRET_KEY and
#      CLOUDFLARE_TUNNEL_TOKEN, then:
#        docker compose -f docker-compose.cloudflare.yml up -d
#
# NUM_PROXIES stays at the default of 2 (Cloudflare Tunnel + nginx-agent = 2
# hops) so PEMForge sees the real client IP for login lockouts/audit logs.

networks:
  forge-net:
    driver: bridge

volumes:
  pemforge_data:

services:
  pemforge:
    image: ridgway90/pemforge:pemforge
    container_name: pemforge
    restart: unless-stopped
    volumes:
      - pemforge_data:/data
    environment:
      - DB_PATH=/data/pemforge.db
      - PORT=5000
      - TZ=Europe/London
      - SECRET_KEY=${PEMFORGE_SECRET_KEY:?set PEMFORGE_SECRET_KEY in .env}
      - METRICS_TOKEN=${PEMFORGE_METRICS_TOKEN:-}
      - NUM_PROXIES=${NUM_PROXIES:-2}
      - CF_Token=${CF_Token:-}
    networks:
      - forge-net
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    deploy:
      resources:
        limits:
          memory: 1024m
          cpus: '2.00'
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/healthz"]
      start_period: 30s
      interval: 30s
      timeout: 5s
      retries: 3

  nginx-agent:
    # No "ports:" here on purpose - only cloudflared needs to reach this
    # container, over the shared forge-net Docker network by service name.
    image: ridgway90/pemforge:nginx-agent
    container_name: nginx-agent
    restart: unless-stopped
    networks:
      - forge-net
    depends_on:
      - pemforge
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    deploy:
      resources:
        limits:
          memory: 128m
          cpus: '0.50'
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5004/"]
      start_period: 45s
      interval: 30s
      timeout: 5s
      retries: 3

  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN:?set CLOUDFLARE_TUNNEL_TOKEN in .env}
    networks:
      - forge-net
    depends_on:
      - nginx-agent
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    deploy:
      resources:
        limits:
          memory: 128m
          cpus: '0.50'

Caddy - automatic Let's Encrypt HTTPS

Also save this as Caddyfile (fill in your two domains) alongside docker-compose.yml:

# Caddyfile for docker-compose.caddy.yml - copy to "Caddyfile", fill in your
# two real domains, then start the stack. Caddy requests and renews Let's
# Encrypt certificates for both automatically - no other config needed.

pemforge.example.com {
    reverse_proxy nginx-agent:5000
}

pemforge-agent.example.com {
    reverse_proxy nginx-agent:5004
}

# PEMForge - public deployment fronted by Caddy (automatic Let's Encrypt HTTPS)
#
# Same pemforge + nginx-agent services as docker-compose.pub.yml (keep them in
# sync if you edit one), plus a Caddy container that terminates TLS for two
# domains and reverse-proxies to nginx-agent over the internal Docker network.
# nginx-agent has no host port binding here - only Caddy is exposed.
#
# Setup:
#   1. Point two DNS A/AAAA records at this host: one for the UI, one for the
#      agent API (e.g. pemforge.example.com and pemforge-agent.example.com).
#   2. Copy Caddyfile.example -> Caddyfile and fill in those two domains.
#   3. Copy .env.pub.example -> .env, fill in PEMFORGE_SECRET_KEY, then:
#        docker compose -f docker-compose.caddy.yml up -d
#      Caddy requests/renews its own certs automatically - nothing to
#      configure in PEMForge itself for this part.
#
# NUM_PROXIES stays at the default of 2 (Caddy + nginx-agent = 2 hops) so
# PEMForge sees the real client IP for login lockouts/audit logs.

networks:
  forge-net:
    driver: bridge

volumes:
  pemforge_data:
  caddy_data:
  caddy_config:

services:
  pemforge:
    image: ridgway90/pemforge:pemforge
    container_name: pemforge
    restart: unless-stopped
    volumes:
      - pemforge_data:/data
    environment:
      - DB_PATH=/data/pemforge.db
      - PORT=5000
      - TZ=Europe/London
      - SECRET_KEY=${PEMFORGE_SECRET_KEY:?set PEMFORGE_SECRET_KEY in .env}
      - METRICS_TOKEN=${PEMFORGE_METRICS_TOKEN:-}
      - NUM_PROXIES=${NUM_PROXIES:-2}
      - CF_Token=${CF_Token:-}
    networks:
      - forge-net
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    deploy:
      resources:
        limits:
          memory: 1024m
          cpus: '2.00'
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/healthz"]
      start_period: 30s
      interval: 30s
      timeout: 5s
      retries: 3

  nginx-agent:
    # No "ports:" here on purpose - only Caddy needs to reach this container,
    # over the shared forge-net Docker network by service name.
    image: ridgway90/pemforge:nginx-agent
    container_name: nginx-agent
    restart: unless-stopped
    networks:
      - forge-net
    depends_on:
      - pemforge
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    deploy:
      resources:
        limits:
          memory: 128m
          cpus: '0.50'
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5004/"]
      start_period: 45s
      interval: 30s
      timeout: 5s
      retries: 3

  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - forge-net
    depends_on:
      - nginx-agent
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    deploy:
      resources:
        limits:
          memory: 128m
          cpus: '0.50'

Traefik - automatic Let's Encrypt HTTPS via Docker labels

Add PEMFORGE_DOMAIN, PEMFORGE_AGENT_DOMAIN, and TRAEFIK_ACME_EMAIL to .env first.

# PEMForge - public deployment fronted by Traefik (automatic Let's Encrypt HTTPS)
#
# Same pemforge + nginx-agent services as docker-compose.pub.yml (keep them in
# sync if you edit one), plus a Traefik container that discovers nginx-agent
# via Docker labels and terminates TLS for two domains. nginx-agent has no
# host port binding here - only Traefik is exposed.
#
# Setup:
#   1. Point two DNS A/AAAA records at this host: one for the UI, one for the
#      agent API (e.g. pemforge.example.com and pemforge-agent.example.com).
#   2. Copy .env.pub.example -> .env, fill in PEMFORGE_SECRET_KEY,
#      TRAEFIK_ACME_EMAIL, PEMFORGE_DOMAIN and PEMFORGE_AGENT_DOMAIN, then:
#        docker compose -f docker-compose.traefik.yml up -d
#      Traefik requests/renews its own certs automatically via the HTTP
#      challenge - nothing to configure in PEMForge itself for this part.
#
# NUM_PROXIES stays at the default of 2 (Traefik + nginx-agent = 2 hops) so
# PEMForge sees the real client IP for login lockouts/audit logs.

networks:
  forge-net:
    driver: bridge

volumes:
  pemforge_data:
  traefik_certs:

services:
  pemforge:
    image: ridgway90/pemforge:pemforge
    container_name: pemforge
    restart: unless-stopped
    volumes:
      - pemforge_data:/data
    environment:
      - DB_PATH=/data/pemforge.db
      - PORT=5000
      - TZ=Europe/London
      - SECRET_KEY=${PEMFORGE_SECRET_KEY:?set PEMFORGE_SECRET_KEY in .env}
      - METRICS_TOKEN=${PEMFORGE_METRICS_TOKEN:-}
      - NUM_PROXIES=${NUM_PROXIES:-2}
      - CF_Token=${CF_Token:-}
    networks:
      - forge-net
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    deploy:
      resources:
        limits:
          memory: 1024m
          cpus: '2.00'
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/healthz"]
      start_period: 30s
      interval: 30s
      timeout: 5s
      retries: 3

  nginx-agent:
    # No "ports:" here on purpose - Traefik reaches this container over the
    # shared forge-net Docker network by service name, per the labels below.
    image: ridgway90/pemforge:nginx-agent
    container_name: nginx-agent
    restart: unless-stopped
    networks:
      - forge-net
    depends_on:
      - pemforge
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    deploy:
      resources:
        limits:
          memory: 128m
          cpus: '0.50'
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5004/"]
      start_period: 45s
      interval: 30s
      timeout: 5s
      retries: 3
    labels:
      - traefik.enable=true
      # UI, port 5000
      - traefik.http.routers.pemforge-ui.rule=Host(`${PEMFORGE_DOMAIN:?set PEMFORGE_DOMAIN in .env}`)
      - traefik.http.routers.pemforge-ui.entrypoints=websecure
      - traefik.http.routers.pemforge-ui.tls.certresolver=le
      - traefik.http.routers.pemforge-ui.service=pemforge-ui
      - traefik.http.services.pemforge-ui.loadbalancer.server.port=5000
      # Agent gateway, port 5004
      - traefik.http.routers.pemforge-agent.rule=Host(`${PEMFORGE_AGENT_DOMAIN:?set PEMFORGE_AGENT_DOMAIN in .env}`)
      - traefik.http.routers.pemforge-agent.entrypoints=websecure
      - traefik.http.routers.pemforge-agent.tls.certresolver=le
      - traefik.http.routers.pemforge-agent.service=pemforge-agent
      - traefik.http.services.pemforge-agent.loadbalancer.server.port=5004

  traefik:
    image: traefik:v3.2
    container_name: traefik
    restart: unless-stopped
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --certificatesresolvers.le.acme.email=${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL in .env}
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik_certs:/letsencrypt
    networks:
      - forge-net
    depends_on:
      - nginx-agent
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          memory: 128m
          cpus: '0.50'

⁠Support

Email: [email protected]⁠

Tag summary

Content type

Image

Digest

sha256:ec3dfb7c6…

Size

26.2 MB

Last updated

3 months ago

docker pull ridgway90/pemforge:nginx-agent