TLS Session Resumption and OCSP Stapling on a Single-vCPU VPS: Reduce Handshake CPU Without Weakening Security

On a small VPS, the RSA or ECDSA signing operation in a TLS 1.3 handshake is often the most expensive thing your CPU does per request. Full handshakes from cold clients can consume 5–15% of a single vCPU at just a few hundred requests per second. The fix is not a larger instance — it is session resumption, ticket key rotation, and OCSP stapling configured so that the expensive asymmetric work happens once per client rather than once per connection. This is the configuration and the measurement to prove it worked.

Where the CPU Actually Goes

In TLS 1.3, a full handshake costs one or two signature operations on the server plus a key exchange. An abbreviated handshake via PSK resumption costs a symmetric operation and no signature at all. Measure the difference directly:

# openssl speed: know your signature cost before tuning
openssl speed -seconds 2 rsa2048 ecdsap256        # legacy paths
# on a 1-vCPU KVM guest: rsa2048 ~50-120 sign/s, ecdsap256 ~600-1500 sign/s

# full handshake vs resumption, per-connection cost
for i in 1 2 3; do
  openssl s_client -connect example.com:443 -tls1_3 -no_ticket /dev/null \
    | grep -i 'New, TLSv1.3'
  openssl s_client -connect example.com:443 -tls1_3 /dev/null \
    | grep -i 'Reused, TLSv1.3'
done

Track server-side resumption rate rather than guessing. Nginx exposes it through the log format; Prometheus through nginx_vts or the stub_status counters:

# nginx: add resumption indicators to the access log
log_format tls '$remote_addr $ssl_protocol $ssl_curve $ssl_session_reused '
               '$ssl_session_id "$request" $request_time';
access_log /var/log/nginx/tls.log tls;

# what fraction of connections resumed?
awk '{print $4}' /var/log/nginx/tls.log | sort | uniq -c | sort -rn
# expect "r" (reused) to dominate for repeat visitors

Session Tickets: Rotation Without Breaking Resumption

TLS 1.3 uses stateless session tickets, so no server-side cache is needed — but the ticket key must be shared across workers and rotated on a schedule (Nginx uses a 128-bit key plus a 4-byte name). The classic mistake is enabling tickets with the default single key and never rotating, which lets a stolen key decrypt recorded sessions indefinitely:

# /etc/nginx/nginx.conf
ssl_session_cache   shared:SSL:20m;    # 20 MB ~ 80k sessions per worker set
ssl_session_timeout 4h;
ssl_session_tickets on;

# TLS 1.3 requires an explicit ticket key for stable resumption across
# reloads and multi-worker setups on older nginx builds:
ssl_session_ticket_key /etc/nginx/ticket.key;   # 80 bytes, mode 0600

# generate with correct entropy and permissions
sudo head -c 80 /dev/urandom > /etc/nginx/ticket.key
sudo chmod 600 /etc/nginx/ticket.key
sudo chown root:root /etc/nginx/ticket.key

Rotate the key on a schedule, keeping the previous key for one full ssl_session_timeout window so in-flight sessions remain resumable:

#!/bin/bash
# /etc/cron.monthly/rotate-ticket-key
set -euo pipefail
KEY=/etc/nginx/ticket.key
cp -p "$KEY" "${KEY}.old"
head -c 80 /dev/urandom > "$KEY.new"
chmod 600 "$KEY.new"; chown root:root "$KEY.new"
mv "$KEY.new" "$KEY"
nginx -t && systemctl reload nginx
# remove the .old key after ssl_session_timeout has elapsed — nginx
# keeps it in memory for the running generation only
find /etc/nginx -name 'ticket.key.old' -mtime +1 -delete

OCSP Stapling: Remove the Client-Side Fetch

Without stapling, a client must contact the CA’s OCSP responder, adding a round trip and, more importantly for you, adding a dependency that fails when the responder is slow — a common cause of intermittent TLS stalls. Stapling embeds a signed, cached OCSP response in the handshake. Verify it works before and after:

# /etc/nginx/conf.d/tls.conf
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ca-chain.pem;   # full chain incl. root
resolver 1.1.1.1 [2606:4700:4700::1111] valid=300s ipv6=off;
resolver_timeout 5s;

# verify: look for a non-empty "OCSP Response Status: successful"
openssl s_client -connect example.com:443 -status -servername example.com /dev/null \
  | sed -n '/OCSP Response Status/,/^---/p'

# if it says "no response sent", check the nginx error log — usually a
# missing ssl_trusted_certificate or an unreachable resolver
ConfigurationHandshake CPU (1 vCPU)Client-visible latency
Full handshake, RSA-2048, no stapling~1.0x baseline+1 RTT to OCSP responder
Full handshake, ECDSA P-256~0.15x of RSA cost+1 RTT
Resumption enabled, ECDSA~0.03x0 extra RTT
Resumption + stapling, ECDSA~0.03x0 extra RTT, no CA dependency

Measure the Effect Properly

Use a load test that includes a realistic mix of new and returning clients, and watch CPU, not just requests per second:

# 30s run, 50 connections, HTTP/2, TLS 1.3, ECDSA certificate
h2load -n 30000 -c 50 -m 10 --tls13 https://example.com/ -t 2

# CPU consumed by nginx during the same window
pidstat -p $(pgrep -o nginx) 1 30 | tail -5

# syscall-level view of crypto work
sudo perf top -p $(pgrep -o nginx) --stdio 2>/dev/null | head -20
# expect the top frames to be in the handshake path, then drop away
# once resumption dominates

Certificates are part of this budget too: ECDSA P-256 halves handshake cost versus RSA-2048, and you can serve both with an ssl_certificate pair for compatibility. Once handshake CPU drops, the next bottleneck is usually application-side — if that turns out to be the case, the architecture guidance on virtualserversvps.com covers where to place caching and TLS termination. And if you are still deciding how much CPU to buy in the first place, the hosting overview at virtualserversvps.com is a useful sanity check on whether a bigger instance or better TLS configuration is the cheaper answer.

Checklist

  • Benchmark with openssl speed to know your per-signature cost.
  • Enable ssl_session_cache with an explicit shared zone and a 4-hour timeout.
  • Set and rotate ssl_session_ticket_key monthly; never leave the default key in place.
  • Enable ssl_stapling plus ssl_stapling_verify with a working resolver and full trusted chain.
  • Serve ECDSA and verify with h2load plus pidstat that CPU per request actually fell.

TLS tuning is pure measurement: two configuration changes, one verification command each, and a CPU graph that proves the improvement is real rather than imagined.

Leave a Reply