The Qualys SSL Labs server test grades how a web server handles TLS: which protocol versions it accepts, which ciphers it offers, whether the certificate chain is complete and whether it sends HTTP Strict Transport Security (HSTS). In this tutorial you will configure Nginx on Ubuntu 24.04 with a Let's Encrypt ECDSA certificate and a modern TLS setup, then confirm the A+ grade with SSL Labs and testssl.sh. An Apache equivalent is included at the end.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Nginx installed (
sudo apt install nginx). Ubuntu 24.04 ships Nginx 1.24 with OpenSSL 3.0. - A domain name whose A (and optionally AAAA) record points to the server. This guide uses
your_domainandwww.your_domainas placeholders. - Ports 80 and 443 open. If UFW is enabled, run
sudo ufw allow 'Nginx Full'.
What SSL Labs checks for an A+
SSL Labs scores four areas (certificate, protocol support, key exchange and cipher strength) and then applies caps for known problems. The rules that matter in practice are:
| Requirement | What happens if it is missing |
|---|---|
| Valid, trusted certificate with the full chain | Grade T or B (incomplete chain) |
| TLS 1.0 and 1.1 disabled | Grade capped at B |
| TLS 1.3 supported | Grade capped at A- |
| Forward secrecy (ECDHE) on all suites | Grade capped at B |
| No weak ciphers such as RC4 or 3DES | Grade capped at B or lower |
HSTS header with a max-age of at least 6 months | Grade stays at A, no A+ |
The HSTS preload directive is not required for A+. You will add it only if you decide to submit the domain to the browser preload list later.
NoteLet's Encrypt shut down its OCSP service in 2025, so certificates it issues no longer contain an OCSP URL. OCSP stapling is not needed for an A+ and Nginx would ignore
ssl_staplingfor these certificates, so this guide leaves it out.
Step 1 - Creating the HTTP server block
Certbot needs a server block with your domain in server_name to answer the HTTP-01 challenge. Create one that only serves port 80 for now:
sudo nano /etc/nginx/sites-available/your_domain
server {
listen 80;
listen [::]:80;
server_name your_domain www.your_domain;
root /var/www/your_domain;
index index.html;
}
Create the web root with a test page and enable the site:
sudo mkdir -p /var/www/your_domain
echo '<h1>TLS test</h1>' | sudo tee /var/www/your_domain/index.html
sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Check that the site answers over plain HTTP:
curl -I http://your_domain
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Step 2 - Obtaining an ECDSA certificate with Certbot
Install Certbot and its Nginx plugin from the Ubuntu repositories:
sudo apt update
sudo apt install certbot python3-certbot-nginx
Request the certificate with certonly so Certbot does not rewrite your server block; you will write the TLS settings yourself in the next step. Certbot 2.x issues ECDSA (P-256) keys by default, but setting --key-type makes it explicit. The --deploy-hook is saved in the renewal configuration and reloads Nginx each time the certificate is renewed:
sudo certbot certonly --nginx --key-type ecdsa \
-d your_domain -d www.your_domain \
--deploy-hook "systemctl reload nginx"
Confirm the key type of the new certificate:
sudo openssl x509 -in /etc/letsencrypt/live/your_domain/cert.pem -noout -text | grep 'Public Key Algorithm'
Public Key Algorithm: id-ecPublicKey
An ECDSA P-256 key gives the same security as a 3072-bit RSA key with faster handshakes. RSA 2048 also earns an A+, so if you need to support very old clients you can use --key-type rsa instead.
Step 3 - Writing a shared TLS configuration
Keeping the TLS settings in their own file lets every site on the server reuse them. The cipher list below is the Mozilla "intermediate" profile without the legacy DHE suites: only ECDHE key exchange with AES-GCM or ChaCha20-Poly1305.
sudo nano /etc/nginx/snippets/tls-a-plus.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:prime256v1:secp384r1;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000" always;
A few details worth knowing:
ssl_ciphersonly affects TLS 1.2. OpenSSL enables the three TLS 1.3 suites automatically and all of them are strong, so they are not listed here.ssl_prefer_server_ciphers offlets each client choose; every suite on the list is strong, and mobile devices without AES hardware can pick ChaCha20.ssl_session_tickets offavoids reusing the same ticket key for the life of the Nginx process, which would weaken forward secrecy.max-age=63072000is two years. SSL Labs needs at least 15552000 seconds (180 days) for the A+.
Warningonce browsers receive the HSTS header they refuse plain HTTP for your domain until
max-ageexpires. Make sure HTTPS works on every name covered by the header before you deploy it. AddincludeSubDomainsonly when all subdomains serve HTTPS.
Step 4 - Enabling HTTPS in the server block
Replace the contents of the site file with an HTTP-to-HTTPS redirect and an HTTPS server that includes the snippet:
sudo nano /etc/nginx/sites-available/your_domain
server {
listen 80;
listen [::]:80;
server_name your_domain www.your_domain;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name your_domain www.your_domain;
ssl_certificate /etc/letsencrypt/live/your_domain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;
include snippets/tls-a-plus.conf;
root /var/www/your_domain;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Always point ssl_certificate at fullchain.pem, not cert.pem. Serving only the leaf certificate leaves the chain incomplete, which SSL Labs reports as "Chain issues: Incomplete".
NoteNginx 1.24 uses
listen 443 ssl http2. The separatehttp2 on;directive only exists from Nginx 1.25.1, so do not copy it from newer examples.
Test the configuration and reload:
sudo nginx -t && sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Step 5 - Verifying the configuration locally
Before running the public test, check the essentials from the server or your workstation. First, confirm the HSTS header is present:
curl -sI https://your_domain | grep -i strict-transport
strict-transport-security: max-age=63072000
Confirm TLS 1.3 negotiates:
openssl s_client -connect your_domain:443 -servername your_domain -tls1_3 </dev/null 2>/dev/null | grep -E 'Protocol|Cipher'
Protocol : TLSv1.3
Cipher : TLS_AES_256_GCM_SHA384
Then confirm TLS 1.1 is refused. The OpenSSL client on Ubuntu 24.04 also refuses TLS 1.1 on its own, so an error here is expected either way; the definitive check is testssl.sh or SSL Labs:
openssl s_client -connect your_domain:443 -servername your_domain -tls1_1 </dev/null
For a complete local scan, run testssl.sh from your workstation. Clone it from the official repository and run it against the domain:
git clone --depth 1 https://github.com/testssl/testssl.sh.git
cd testssl.sh
./testssl.sh --protocols --server-defaults --headers your_domain
In the protocol section you should see only TLS 1.2 and TLS 1.3 offered:
SSLv2 not offered (OK)
SSLv3 not offered (OK)
TLS 1 not offered
TLS 1.1 not offered
TLS 1.2 offered (OK)
TLS 1.3 offered (OK): final
Step 6 - Running the SSL Labs test
Open https://www.ssllabs.com/ssltest/ in a browser, enter your_domain, and tick "Do not show the results on the boards" if you do not want the result listed publicly. The scan takes one to two minutes per IP address; a domain with both IPv4 and IPv6 records is tested on each address.
With the configuration above, the summary shows an overall rating of A+, with the note "HTTP Strict Transport Security (HSTS) with long duration deployed on this server" below the grade.
To re-test after a change, use the "Clear cache" link on the results page. Otherwise SSL Labs may show a cached report.
Step 7 - Keeping the rating over time
Certbot installs a systemd timer that attempts renewal twice a day. Check that it is active and that renewal, including the deploy hook, works:
systemctl list-timers certbot.timer
sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/your_domain/fullchain.pem (success)
An expired certificate drops the grade to T, so the timer is the most important part of maintenance. Re-run the SSL Labs test after Nginx or OpenSSL upgrades and after any change to the TLS snippet.
Optional - HSTS preload
Browsers ship a list of domains that are HTTPS-only from the very first visit. To qualify, the header must include includeSubDomains and preload, with a max-age of at least one year, and every subdomain must serve HTTPS:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
After deploying it, submit the domain at https://hstspreload.org. Removal from the list takes months, so only do this for domains you are sure will stay HTTPS-only.
Apache equivalent
If you run Apache 2.4 on Ubuntu 24.04 instead of Nginx, enable the modules and use the same certificate:
sudo a2enmod ssl headers
Add these directives to the <VirtualHost *:443> block of your site (for example /etc/apache2/sites-available/your_domain-le-ssl.conf):
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/your_domain/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/your_domain/privkey.pem
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
SSLHonorCipherOrder off
SSLSessionTickets off
Header always set Strict-Transport-Security "max-age=63072000"
Since Apache 2.4.8, SSLCertificateFile accepts the full chain, so SSLCertificateChainFile is not needed. Check and reload with sudo apache2ctl configtest && sudo systemctl reload apache2.
Troubleshooting
Grade is A instead of A+. The HSTS header is missing on the response SSL Labs fetched. Nginx does not inherit add_header directives into a location block that defines its own add_header, so a header set there removes HSTS for that path. Repeat the Strict-Transport-Security line inside such locations (the snippet itself cannot be included there because ssl_* directives are not allowed in a location), or move the other headers to the server level.
"Chain issues: Incomplete" and grade B. ssl_certificate points to cert.pem. Change it to fullchain.pem and reload.
nginx -t reports a duplicate ssl_protocols or ssl_session_cache. The same directive is set in /etc/nginx/nginx.conf or in /etc/letsencrypt/options-ssl-nginx.conf (added if you once ran certbot --nginx without certonly). Directives in the http block are overridden by the server block, but duplicates in the same block are errors. Remove the include /etc/letsencrypt/options-ssl-nginx.conf; line from the server block.
IPv6 address gets a different grade. The AAAA record points to an address where the listen [::]:443 line is missing, or to another server. Check both addresses in the SSL Labs report.
Conclusion
Your Nginx server now accepts only TLS 1.2 and 1.3 with forward-secret AEAD ciphers, serves a complete ECDSA certificate chain and sends a long-lived HSTS header, which is what SSL Labs requires for an A+. From here you can add a DNS CAA record that restricts issuance to Let's Encrypt, submit the domain to the HSTS preload list once every subdomain is HTTPS-only, or reuse snippets/tls-a-plus.conf for the other sites on the server.
