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 sudo privileges.
  • 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_domain and www.your_domain as 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:

RequirementWhat happens if it is missing
Valid, trusted certificate with the full chainGrade T or B (incomplete chain)
TLS 1.0 and 1.1 disabledGrade capped at B
TLS 1.3 supportedGrade capped at A-
Forward secrecy (ECDHE) on all suitesGrade capped at B
No weak ciphers such as RC4 or 3DESGrade capped at B or lower
HSTS header with a max-age of at least 6 monthsGrade 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.

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_ciphers only 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 off lets each client choose; every suite on the list is strong, and mobile devices without AES hardware can pick ChaCha20.
  • ssl_session_tickets off avoids reusing the same ticket key for the life of the Nginx process, which would weaken forward secrecy.
  • max-age=63072000 is two years. SSL Labs needs at least 15552000 seconds (180 days) for the A+.

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".

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.