Hockeypuck OpenPGP Keyserver 2.4.1 on AWS User Guide
Overview
This guide covers launching and using the Hockeypuck OpenPGP Keyserver 2.4.1 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg identity, secrets and PKI listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.
Hockeypuck is an open source OpenPGP keyserver. It stores OpenPGP public keys and serves them over the HTTP Keyserver Protocol (HKP): gpg --send-keys and gpg --recv-keys, gpg --search-keys, the /pks/lookup and /pks/add endpoints, and the newer HKPv2 endpoints (/pks/v2/...). Version 2.4 supports RFC 9580 (v6) and post-quantum keys. Organisations run their own keyserver to publish their staff's and release-signing keys, to give internal tooling a key directory that does not depend on the public pool, or to join the public keyserver network. Hockeypuck also has a small web page for searching and submitting keys.
The cloudimg image is a hardened, ready-to-use keyserver:
- A cloudimg build of the upstream 2.4.1 release. Built from the upstream
2.4.1tag with Go 1.26.10 and the repository's own vendored dependencies; no Hockeypuck source file, template or web page is changed. The exact source, build record and licence are on the image in/usr/share/doc/cloudimg-hockeypuck/. - HTTPS from the first boot. nginx is the only public listener (ports 80 and 443) and serves a certificate generated on your instance (an end-entity certificate, not a certificate authority); port 80 redirects to HTTPS. HKP is served as
hkps://on port 443. The public name is set from the instance's EC2 public DNS name at first boot and can be changed to your own domain. - An empty key database, created on your instance. First boot creates the PostgreSQL cluster and Hockeypuck's database. No key, test key or database from the build ships in the image.
- PostgreSQL with no password at all. PostgreSQL 16 has no TCP listener; Hockeypuck connects over its Unix socket with peer authentication, so no database password exists.
- Peering is off. SKS reconciliation (gossip with other keyservers, port 11370) is switched off, so the server neither imports the public keyspace nor forwards keys anywhere until you configure partners (Step 12). Hockeypuck's own HKP port (11371) and its metrics (9626) listen on the loopback address only.
- Key submission is open, as on every public keyserver, and rate limited per client address by nginx; Hockeypuck refuses oversized keys and packets. No admin key is configured, so the signed delete and replace endpoints refuse every request until you add one;
sudo hockeypuck-delete-keyremoves a key for you through that signed path (for example for an erasure request). - Real client addresses. nginx hands Hockeypuck the real TCP peer, never a client's own
X-Forwarded-For, so logs and rate limits apply to the real client address. - Metadata access blocked. Hockeypuck can forward keys to other keyservers and fetch from peers you configure. Every service is blocked from the instance metadata service, and the AMI requires IMDSv2.
- Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.
What is on the image: Hockeypuck 2.4.1 (hockeypuck, hockeypuck-dump, hockeypuck-load, hockeypuck-pbuild, hockeypuck-reload), nginx, PostgreSQL 16, GnuPG, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.
Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large
Prerequisites
- An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet.
- A security group that allows TCP 22 (SSH) from your own networks only, and TCP 443 (the keyserver) and TCP 80 (redirects to HTTPS) from the people and systems that use the keyserver. Anyone who can reach port 443 can look up and submit keys, so for an internal keyserver restrict 80/443 to your own networks. Never open 5432, 9626, 11370 or 11371: they listen on the loopback address only, or not at all.
- For production, a DNS name you control (for example
keys.example.com) and a certificate from a certificate authority for it. GnuPG (gpg --keyserver hkps://...) checks the server certificate against the system's trusted authorities.
Step 1: Launch the instance
In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick Hockeypuck OpenPGP Keyserver 2.4.1 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large, your key pair, and a security group as above.
With the AWS CLI (replace the values in angle brackets):
aws ec2 run-instances --image-id <ami-id-from-the-listing> --instance-type m5.large \
--key-name <your-key-pair> --security-group-ids <your-security-group> --subnet-id <your-subnet> \
--metadata-options HttpTokens=required,HttpEndpoint=enabled \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=hockeypuck}]'
First boot takes about ten seconds after cloud-init has run. If you want your own name from the start, put it in /etc/cloudimg/hockeypuck-domain.preset with user data (for example a cloud-init write_files entry); otherwise first boot uses the instance's public DNS name, or its private name if it has none.
Step 2: Connect to your instance
| Operating system | SSH user |
|---|---|
| Ubuntu 24.04 | ubuntu |
ssh -i <your-key.pem> ubuntu@<public-ip>
Step 3: Check the services
systemctl is-active hockeypuck-firstboot postgresql@16-main hockeypuck nginx
active
active
active
active
Only SSH, HTTP and HTTPS are public. Hockeypuck's HKP port (11371) and metrics (9626) are on the loopback address; there is no peering port (11370) and PostgreSQL has no TCP listener:
sudo ss -tln | awk '{print $4}' | grep -E ':(22|80|443|5432|9626|11370|11371)$' | sort -u
0.0.0.0:22
0.0.0.0:443
0.0.0.0:80
127.0.0.1:11371
127.0.0.1:9626
[::]:22
[::]:443
[::]:80
A health endpoint for monitoring agents on the instance itself:
curl -s http://127.0.0.1/pks/health; echo
OK
Step 4: Your keyserver's address and certificate
sudo cat /root/hockeypuck-credentials.txt
The file (root only, mode 0600) gives the keyserver's address, the SHA-256 fingerprint of the instance's certificate and its public-key pin. There is no password in it: none exists.
# Hockeypuck 2.4.1 OpenPGP keyserver - written on first boot by hockeypuck-firstboot.service. Unique to this instance.
HOCKEYPUCK_URL=https://ec2-3-82-154-192.compute-1.amazonaws.com
HOCKEYPUCK_HKPS=hkps://ec2-3-82-154-192.compute-1.amazonaws.com
HOCKEYPUCK_TLS_SHA256=87:74:0A:2B:E3:F2:93:83:BA:8B:AE:81:F8:70:07:72:93:D2:F0:CD:1A:3D:EB:4B:7B:A1:EA:41:C7:8A:9E:C4
HOCKEYPUCK_TLS_SPKI_SHA256=6gIPmFKRQnAeF6cqJqo2iVT5ttOyPCL59RzMjBwqpno=
#
# The keyserver (web search page, HKP lookup and key submission) is served over HTTPS at HOCKEYPUCK_URL with a
# self-signed certificate unique to this instance: compare its SHA-256 fingerprint (above) with the one your client
# shows, pin it (curl --pinnedpubkey sha256//<HOCKEYPUCK_TLS_SPKI_SHA256>), or replace it with a CA-issued
# certificate for your own name (needed for gpg --keyserver hkps://...; see the guide).
# No password exists: PostgreSQL is reached over its unix socket with peer authentication only, and no admin key is
# configured, so /pks/delete and /pks/replace refuse every request until you list one in /etc/cloudimg/hockeypuck-admin-keys.
# Remove a key (e.g. an erasure request): sudo hockeypuck-delete-key <fingerprint>
# Peering (SKS reconciliation) is off; key submission is open and rate limited (see the guide).
# Your own name (point DNS at this instance first): sudo hockeypuck-set-domain keys.example.com
# User guide: https://www.cloudimg.co.uk/guides/hockeypuck-openpgp-keyserver-2-4-1-on-ubuntu-24-04-aws/
The certificate is self-signed and unique to your instance. Do not add it to a trust store: compare its fingerprint with the one your browser or client shows, pin its public key, or replace it with a CA-issued certificate (Step 11).
Step 5: Run the self-test
sudo hockeypuck-selftest
It makes a throwaway key in a private temporary keyring, submits it over HTTPS, finds it by fingerprint (the served key re-imports with the same fingerprint), by e-mail address and through HKPv2, checks that a delete request signed by a key that is not an admin key is refused, then removes the key through the signed admin path and checks it is gone. Hockeypuck restarts twice during the last step. GnuPG prints its trust-database notes on the way:
gpg: checking the trustdb
gpg: marginals needed: 3 completes needed: 1 trust model: pgp
gpg: depth: 0 valid: 1 signed: 0 trust: 0-, 0q, 0n, 0m, 0f, 1u
gpg: next trustdb check due at 2026-11-01
round trip OK: submit, get by fingerprint (re-imports), index by e-mail, HKPv2; non-admin delete refused; admin delete OK
HOCKEYPUCK_SELFTEST_OK
Step 6: The web search page
Open HOCKEYPUCK_URL in a browser. Your browser warns about the self-signed certificate: check that the fingerprint it shows matches HOCKEYPUCK_TLS_SHA256 before you continue.

Submit Key opens a form where you paste an ASCII-armored public key (gpg --armor --export <your-key-id>):

Hockeypuck answers with the fingerprint it stored:

Searching for the key's e-mail address lists it with its fingerprint, user ID and self-signature. Hockeypuck 2.4 matches user IDs exactly (the whole e-mail address), or a key by its 0x fingerprint or key ID:

The stats link at the bottom shows the server's version, its name and the number of keys:

The page loads no scripts and is served with a strict Content-Security-Policy.
Step 7: Submit and look up a key over HKP
Any HKP client works. This example runs on the instance and uses curl with the certificate's public-key pin from Step 4 (from your workstation, drop --resolve and use the instance's name). It makes a demo key in a temporary keyring, submits it, lists it by e-mail address in HKP's machine-readable format, fetches it by fingerprint and checks what came back:
export GNUPGHOME=$(mktemp -d)
gpg --batch --quiet --pinentry-mode loopback --passphrase '' --quick-gen-key "Demo User <demo@example.com>" ed25519 sign 1d 2>/dev/null
FPR=$(gpg --batch --with-colons --list-keys 2>/dev/null | awk -F: '$1=="fpr"{print tolower($10); exit}')
echo "$FPR" > /tmp/demo.fpr
gpg --batch --armor --export "$FPR" > /tmp/demo.asc
NAME=$(cat /etc/cloudimg/hockeypuck-domain)
PIN=$(sudo sed -n 's/^HOCKEYPUCK_TLS_SPKI_SHA256=//p' /root/hockeypuck-credentials.txt)
HKP=(curl -sS -k --pinnedpubkey "sha256//$PIN" --resolve "$NAME:443:127.0.0.1")
"${HKP[@]}" --data-urlencode keytext@/tmp/demo.asc "https://$NAME/pks/add" | tr -d '\n' | grep -o 'Inserted<.*(4)[0-9a-f]*' | sed 's/<[^>]*>//g; s/(4)/ /'
"${HKP[@]}" "https://$NAME/pks/lookup?op=index&options=mr&search=demo@example.com" | cut -d: -f1-4
"${HKP[@]}" "https://$NAME/pks/lookup?op=get&options=mr&search=0x$FPR" | GNUPGHOME=$(mktemp -d) gpg --batch --import --import-options show-only --with-colons 2>/dev/null | awk -F: '$1=="fpr"{print "served key fingerprint: " tolower($10); exit}'
"${HKP[@]}" -o /dev/null -w 'HKPv2 by fingerprint: %{http_code}\n' "https://$NAME/pks/v2/certs/by-vfingerprint/04/$FPR"
Inserted 50c27d8fc2706a7f8637109d3fc13f4f9e289d34
info:1:1
pub:50C27D8FC2706A7F8637109D3FC13F4F9E289D34:22:263
uid:Demo User <demo@example.com>:1793440921:1793527321
served key fingerprint: 50c27d8fc2706a7f8637109d3fc13f4f9e289d34
HKPv2 by fingerprint: 200
--pinnedpubkey makes curl refuse any other certificate, even with -k; with a CA-issued certificate you need neither.
Step 8: Use the keyserver with GnuPG
GnuPG's dirmngr checks the server certificate against your system's trusted certificate authorities, so give your keyserver a DNS name and a CA-issued certificate first (Step 11). Then, from any workstation:
gpg --keyserver hkps://keys.example.com --send-keys <your-key-fingerprint>
gpg --keyserver hkps://keys.example.com --recv-keys <their-key-fingerprint>
gpg --keyserver hkps://keys.example.com --search-keys alice@example.com
To make it the default, put keyserver hkps://keys.example.com in ~/.gnupg/dirmngr.conf. Until you have a CA-issued certificate, fetch keys with curl and the pin from Step 4 and pipe them into gpg --import.
Step 9: Remove a key
Keyservers keep what they are given. To remove a key (for example when its owner asks for erasure), run hockeypuck-delete-key with its full fingerprint. It signs a /pks/delete request with a throwaway admin key made in a private temporary keyring, so Hockeypuck's own bookkeeping stays consistent, and removes the throwaway key afterwards; Hockeypuck restarts twice. This removes the demo key from Step 7:
sudo hockeypuck-delete-key "$(cat /tmp/demo.fpr)" 2>/dev/null
NAME=$(cat /etc/cloudimg/hockeypuck-domain)
curl -sk -o /dev/null -w 'lookup after the delete: %{http_code}\n' --resolve "$NAME:443:127.0.0.1" "https://$NAME/pks/lookup?op=get&options=mr&search=0x$(cat /tmp/demo.fpr)"
rm -f /tmp/demo.fpr /tmp/demo.asc
0x50c27d8fc2706a7f8637109d3fc13f4f9e289d34: deleted
lookup after the delete: 404
A request to /pks/delete or /pks/replace that is not signed by an admin key is refused. To sign such requests yourself (for example from an operations workstation), add your own admin key's fingerprint to /etc/cloudimg/hockeypuck-admin-keys, submit that public key to the keyserver, and re-render the configuration:
echo <your-admin-key-fingerprint> | sudo tee -a /etc/cloudimg/hockeypuck-admin-keys
sudo hockeypuck-render-config
Step 10: Who may submit keys
Key submission (/pks/add, /pks/v2/certs) is open, as on every public keyserver, so anyone who can reach port 443 can add public keys. The defaults keep that safe:
- nginx limits submissions (and the signed admin endpoints) to 6 per minute per client address, with a burst of 12, and lookups to 20 per second with a burst of 100; it refuses submissions over 3 MiB;
- Hockeypuck refuses keys over 1 MiB and packets over 8 KiB (the limits that stop the "poisoned key" floods seen on the public network), and you can refuse specific keys with
blacklistin the template; - peering is off, so nothing submitted here is forwarded to other servers unless you configure it.
For an internal keyserver, restrict the security group's 80/443 rules to your own networks. To make the server read-only for the internet but writable from your office, edit /usr/local/lib/cloudimg/hockeypuck/nginx-site.tmpl: in the location ~ ^/pks/(add|v2/certs|delete|replace|hashquery)$ block add allow 203.0.113.0/24; (your range) and deny all;, then run sudo hockeypuck-set-domain with your current name (it re-renders nginx and makes a new certificate).
Step 11: Use your own domain and certificate
Point a DNS record at the instance, then:
sudo hockeypuck-set-domain keys.example.com
This re-renders nginx and Hockeypuck for the new name and makes a new self-signed certificate for it. Replace /etc/ssl/hockeypuck/hockeypuck.crt and hockeypuck.key with a CA-issued certificate for the name (for example from your organisation's CA, or Let's Encrypt with certbot certonly --webroot after allowing /.well-known/acme-challenge/ in the template) and run sudo systemctl reload nginx.
Step 12: Peering with other keyservers (optional)
Peering (SKS reconciliation) is off by default. Hockeypuck 2.4 peers only with other 2.4 servers. To peer, agree partners with their operators, then in /usr/local/lib/cloudimg/hockeypuck/hockeypuck.conf.tmpl:
[hockeypuck.conflux.recon]
reconAddr="0.0.0.0:11370"
allowCIDRs=["127.0.0.1/8"]
[hockeypuck.conflux.recon.partner.keys_partner_example]
httpAddr="keys.partner.example:11371"
reconAddr="keys.partner.example:11370"
pksFailover=true
Your partners also fetch keys from your HKP port, so make Hockeypuck's HKP reachable to them: add an nginx server that listens on 11371 and proxies to 127.0.0.1:11371 (or set bind="0.0.0.0:11371"). Run sudo hockeypuck-render-config, then open TCP 11370 and 11371 in the security group to your partners' addresses only. Joining the public pool imports its whole keyspace (many gigabytes): size the instance and its disk first, and see the upstream documentation at https://hockeypuck.io/. You can also forward new keys to other servers with PKS targets in /etc/cloudimg/hockeypuck-pks-targets (one hkps://host per line) and sudo hockeypuck-render-config.
Step 13: Check the security posture
Every service is blocked from the instance metadata service, and core dumps are off:
systemctl show -p IPAddressDeny -p LimitCORE hockeypuck nginx postgresql@16-main hockeypuck-firstboot
IPAddressDeny=::ffff:169.254.169.254/128 fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
LimitCORE=0
IPAddressDeny=::ffff:169.254.169.254/128 fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
LimitCORE=0
IPAddressDeny=::ffff:169.254.169.254/128 fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
LimitCORE=0
IPAddressDeny=::ffff:169.254.169.254/128 fd00:ec2::254/128 168.63.129.16/32 169.254.169.254/32
LimitCORE=0
PostgreSQL has no role with a password and no TCP listener, and only the hockeypuck service account may use the hkp database (your own login is refused):
sudo -u postgres psql -X -tA -c "SELECT count(*) AS roles_with_a_password FROM pg_authid WHERE rolpassword IS NOT NULL"
sudo -u postgres psql -X -tA -c "SHOW listen_addresses" | sed 's/^$/(no TCP listener)/'
sudo -u ubuntu psql -X -h /var/run/postgresql -U hockeypuck -d hkp -c 'SELECT 1' 2>&1 | grep -o 'Peer authentication failed for user "hockeypuck"' || true
0
(no TCP listener)
Peer authentication failed for user "hockeypuck"
Hockeypuck listens on the loopback address only, peering is off, metrics stay local, no admin key and no forwarding target is configured:
sudo grep -E '^(bind|reconAddr|metricsAddr|adminKeys|to)=' /etc/hockeypuck/hockeypuck.conf
adminKeys=[]
bind="127.0.0.1:11371"
metricsAddr="127.0.0.1:9626"
to=[]
reconAddr="none"
nginx refuses old TLS versions and unknown host names, port 80 only redirects, and Hockeypuck's own port does not answer on the network address:
D=$(cat /etc/cloudimg/hockeypuck-domain); IP=$(hostname -I | awk '{print $1}')
echo | timeout 15 openssl s_client -connect "$IP:443" -servername "$D" -tls1_1 -cipher 'DEFAULT:@SECLEVEL=0' 2>&1 | grep -m1 -oE 'alert protocol version|no protocols available'
curl -s -m 15 -o /dev/null -w 'port 80: %{http_code}\n' --resolve "$D:80:$IP" "http://$D/"
curl -sk -m 15 -o /dev/null -w 'unknown Host on 443: %{http_code}\n' -H 'Host: evil.example' "https://$IP/" || true
curl -s -m 5 -o /dev/null -w 'HKP port 11371 on the private address: %{http_code}\n' "http://$IP:11371/pks/health" || true
alert protocol version
port 80: 301
unknown Host on 443: 000
HKP port 11371 on the private address: 000
Step 14: Your data and backups
Every key is in the PostgreSQL database hkp; Hockeypuck's reconciliation state in /var/lib/hockeypuck is rebuilt from it (hockeypuck-pbuild). Back the database up with pg_dump:
sudo -u postgres pg_dump -Fc hkp | sudo tee /var/backups/hkp-$(date +%F).dump >/dev/null
sudo ls -l /var/backups/ | grep hkp
-rw-r--r-- 1 root root 7914 Oct 31 10:02 hkp-2026-10-31.dump
hockeypuck-dump and hockeypuck-load export and import keys as OpenPGP keyring files (the format other keyservers exchange); in 2.4.1 the dump can run while the server is running.
Step 15: Licences and source
Hockeypuck is licensed under the GNU Affero General Public License v3. Users of your keyserver over a network are entitled to its corresponding source, which is the upstream tag plus the three dependency files cloudimg changed:
head -16 /usr/share/doc/cloudimg-hockeypuck/SOURCE.txt
sha256sum /usr/local/bin/hockeypuck*
Hockeypuck 2.4.1 - corresponding source (GNU Affero General Public License v3, section 13)
This appliance runs Hockeypuck (github.com/hockeypuck/hockeypuck, AGPL-3.0-only; the conflux/ packages are
GPL-3.0-or-later; (c) Casey Marshall and the Hockeypuck Contributors). Users who interact with it over a network are
entitled to its corresponding source. It is:
* the upstream source at tag 2.4.1 (commit c5555b86ac8deafbad71371c5c369f8fdf04be73):
https://github.com/hockeypuck/hockeypuck/tree/2.4.1
(the repository vendors every Go dependency under src/hockeypuck/vendor/);
* with exactly three files replaced: src/hockeypuck/go.mod, src/hockeypuck/go.sum and src/hockeypuck/vendor/modules.txt,
as shipped in this directory (go.mod, go.sum, vendor-modules.txt). They raise golang.org/x/crypto from v0.55.0 to
v0.56.0 (dependency metadata only; the vendored package files are identical). No Hockeypuck source file, template
or web page is changed (tree-delta.txt).
Build: go1.26.10, CGO_ENABLED=0 GOOS=linux GOARCH=amd64 -mod=vendor, see BUILD-INFO for the exact command line and
SHA256SUMS for the five binaries in /usr/local/bin. The HTML templates and web root in /usr/share/hockeypuck are the
f2c201270c31469805101a1baccfb33e30fa9fbdb5b152ba4d9c0ac133441c17 /usr/local/bin/hockeypuck
030b4a91ca70a3a8e4021fa1655b34d428df597d69cb2bbfa18840d5e34fa688 /usr/local/bin/hockeypuck-dump
649b72b114bbaf3098afec7d33d491807a3695815cdffcd7b67538bec9d173a3 /usr/local/bin/hockeypuck-load
05e5c9af0e9e28e80752fbfb7bfa0e57186467dcc667e764bd33ccfc7a3bc18e /usr/local/bin/hockeypuck-pbuild
6b5fa9ea2e27f28d02df6a57f9bed700e250a3c72690051e771b6bb455bf8f86 /usr/local/bin/hockeypuck-reload
Step 16: Security notes
- Advisories. No security advisory is published for Hockeypuck. Built with Go 1.26.10,
govulncheckreports no reachable vulnerability in any of the five binaries. The one advisory a scanner may list, GO-2026-5932 (the deprecatedgolang.org/x/crypto/openpgppackage), has no fix and does not apply: Hockeypuck does not link that package (it uses the maintained ProtonMail OpenPGP library). - A keyserver publishes what it is given. Keys and user IDs (names and e-mail addresses) on the server are public to anyone who can reach it. Tell your users so, and use Step 9 for erasure requests.
- Exact matching. Hockeypuck 2.4 matches user IDs exactly by default; set
enableInexact=trueunder[hockeypuck.hkp.queries]in the template if you want keyword search. - Malformed searches. A search string PostgreSQL cannot store (for example one containing a NUL byte) is answered with HTTP 500 by Hockeypuck; the server keeps serving.
- Cross-origin reads. Like every keyserver, Hockeypuck allows any web page to read its public lookups (
Access-Control-Allow-Origin: *); no credentials or cookies exist, so nothing private is exposed.
Support
cloudimg provides support for this image by e-mail at support@cloudimg.co.uk. Hockeypuck itself is documented at https://hockeypuck.io/. Hockeypuck is an open source project of Casey Marshall and the Hockeypuck Contributors; cloudimg is not affiliated with or endorsed by the Hockeypuck project.