Install with Docker Compose¶
Run gpm as a container: verify the image, mount one data volume, publish the two proxy listener ports, and keep the admin panel off the internet.
Verifying the image¶
Every image pushed by the release workflow (including the latest tag, which
shares the same manifest digest as the version tags built alongside it) is
signed keylessly with cosign via GitHub
Actions OIDC: no key material to manage or leak. Verify before you deploy:
cosign verify \
--certificate-identity-regexp '^https://github.com/Rake-Pro/go-proxy-manager/\.github/workflows/release\.yml@refs/(heads/main|tags/v.*)$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/rake-pro/go-proxy-manager:latest
A successful verification prints the signature payload and confirms the image
was built by the release.yml workflow in this repository from a v* tag,
not from a fork, a different workflow, or a hand-pushed image.
Compose file¶
services:
gpm:
image: ghcr.io/rake-pro/go-proxy-manager
restart: unless-stopped
ports:
- "443:443"
- "80:80"
- "127.0.0.1:8081:8081" # admin: loopback only
environment:
GPM_LOCAL_ADMIN_USER: admin
GPM_LOCAL_ADMIN_PASSWORD_HASH_FILE: /run/secrets/admin_hash
volumes:
- gpm-data:/data
secrets:
- admin_hash
- cf_token # for ACME (optional)
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
secrets:
admin_hash:
file: ./secrets/admin_hash # from `gpm hashpw`; chmod 644
cf_token:
file: ./secrets/cf_token # Cloudflare API token; chmod 644
volumes:
gpm-data:
Docker secret file permissions: plain
docker composemounts file-based secrets with the host file's own permissions. The non-rootgpmuser must be able to read them, sochmod 644the secret files (theuid/gid/modesecret options only apply in Swarm).First login needs no cookie setting.
GPM_COOKIE_SECUREdefaults toauto: the admin session cookie is issued withoutSecureoverhttp://127.0.0.1:8081, and becomesSecure(with the__Host-name) automatically once the request reaches gpm over TLS, arrives through a trusted proxy sendingX-Forwarded-Proto: https, orexternalBaseURLis anhttps://URL.
Data directory¶
A single volume mounted at /data:
/data/config git-backed config repo (see reference/config/)
/data/certs certificate store (custom certs + ACME-issued artifacts,
client-CA CRLs, client-CA signing keys under client-cas/,
and client-certificate issuance records under client-certs/)
/data/session.db SQLite session store (pure-Go, no CGO)
The container runs as a non-root user; make sure the mounted volume is writable by
it (the image's gpm user). The object model stored under /data/config is
documented in the configuration reference.
client-cas/<name>.key is where a generated ClientCA's signing key lands
(POST /api/client-cas/{name}/generate, or "Generate new CA" in the UI): gpm
writes it at 0600 itself, so nothing has to be provisioned externally to get a
working mTLS setup. A key you place here yourself (caKeyFile pointing anywhere
under the cert store, for a bring-your-own CA) is the same thing by hand: give it
0600 and owner gpm. The alternative is caKeyPEM with a
${FILE:/run/secrets/...} placeholder, which keeps the key in the secret mount
instead.
Either way it is a CA private key: back it up with the rest of /data/certs,
and remember it is not in the git config repo, so a config-only backup does not
carry it: restoring config alone gives you a ClientCA object pointing at a key
that is not there. Deleting a ClientCA does not delete its key file (see
ClientCA), so removing a CA
for good is a config delete plus an rm here. CA generation, certificate issuance
and renewal are all POSTs, so an HA follower refuses them with 503 like
every other write: do them on the leader.
client-certs/<ca>.json holds the issuance records that drive the expiry warning
and the renew action. They are runtime state, not config, so a config-only backup
does not carry them: back them up with the rest of /data/certs, and share the
cert dir between HA peers (which the HA recipe already calls for) if you
want the follower to show the same list. Losing them loses only gpm's memory of
what was issued: the certificates themselves keep working, and the CA keeps
verifying them.
IPv6¶
gpm listens dual-stack out of the box: a bare :80 / :443 bind accepts IPv4 and
IPv6 on the same socket, the router and every middleware are address-family
agnostic, and the client IP an access list, geo rule, rate limit or
X-Forwarded-For sees is whichever address the client actually used: a v6 client
appears as its v6 address, not a v4 stand-in. There is no IPv6 toggle to set.
Pinning a bind to one family is still possible and is a deliberate choice:
GPM_HTTPS_ADDR=0.0.0.0:443 is v4-only, GPM_HTTPS_ADDR=[::]:443 is v6 (with
v4-mapped addresses, so still both on Linux unless the host has
net.ipv6.bindv6only=1).
What usually blocks inbound IPv6 is Docker, not gpm. Standard Docker behaviour:
- The daemon needs IPv6 enabled. Set
"ip6tables": trueand (for a user-defined bridge)"experimental": truewhere your Docker version still requires it in/etc/docker/daemon.json, then restart the daemon. Withoutip6tablesthe daemon does not program v6 NAT/forwarding rules and published ports are not reachable over v6. - The network needs
enable_ipv6. A compose network must declare it (and, on older Compose file versions, a v6 subnet):
yaml
networks:
edge:
enable_ipv6: true
ipam:
config:
- subnet: fd00:dead:beef::/64 # ULA is fine; a routed /64 is better
- The container needs a real IPv6 address, not just a v4 port mapping. A published port on a v4-only network is unreachable over v6 no matter what gpm binds.
userland-proxychanges what the app sees. With the default"userland-proxy": truethe daemon'sdocker-proxyrelays the connection, so the container sees the proxy's address as the peer: the real client IP is lost for both families. Setting"userland-proxy": falsein/etc/docker/daemon.jsonkeeps the connection on the kernel path (iptables / ip6tables DNAT) so the original client address arrives intact.- Host networking is the simplest alternative.
network_mode: hostgives the container the host's addresses directly: no NAT, no userland proxy, real client IPs in both families, at the cost of losing port isolation (gpm then binds the host's:80/:443and every stream port directly).
If the edge in front of gpm is an L4 load balancer rather than the client, the
client IP problem is not a Docker one: turn on
settings.proxyProtocol
so gpm reads the real client address (of either family) out of the PROXY header.