SSL certificate monitoring and automatic ACME renewal for Windows/IIS environments.
| Service | Port | Purpose |
|---|---|---|
| PEMForge | 5000 | SSL certificate monitoring, expiry alerts, CSR/PFX tools, ACME renewal + Windows agent |
| Agent Gateway | 5002 | Rate-limiting proxy for the Windows agent API |
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:
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 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
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.
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'
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'
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'
Email: [email protected]
Content type
Image
Digest
sha256:ec3dfb7c6…
Size
26.2 MB
Last updated
3 months ago
docker pull ridgway90/pemforge:nginx-agent