{"id":310,"date":"2026-08-29T17:22:13","date_gmt":"2026-08-29T17:22:13","guid":{"rendered":"https:\/\/www.vps.tc\/blog\/?p=310"},"modified":"2026-08-29T09:08:12","modified_gmt":"2026-08-29T09:08:12","slug":"install-ssl-certificate-on-vps-lets-encrypt","status":"publish","type":"post","link":"https:\/\/www.vps.tc\/blog\/en\/install-ssl-certificate-on-vps-lets-encrypt\/","title":{"rendered":"How to Install an SSL Certificate on a VPS with Let&#8217;s Encrypt"},"content":{"rendered":"<h2>What to check before installing an SSL certificate on a VPS<\/h2>\n<p>That padlock in a browser does not appear just because a certificate file exists somewhere under <code>\/etc<\/code>. For me, it means the entire path is working: DNS points to the right machine, the web server answers on the expected ports, HTTP validation can reach the VPS, and renewal will happen before the certificate expires.<\/p>\n<p>Let&#8217;s Encrypt makes this free and automatable. On most Linux VPSs, I use Certbot with Nginx. Apache follows much the same route, but it needs a different plugin. Before running anything, find out which web server is active. The right command on the wrong machine is still the wrong command.<\/p>\n<h2>Prepare the domain and the VPS<\/h2>\n<p>Check DNS before starting. <code>example.com<\/code> and, if you use it, <code>www.example.com<\/code> should point to the VPS&#8217;s public IPv4 address. If you use IPv6, check the <code>AAAA<\/code> record too. An old or incorrect <code>AAAA<\/code> record can send validation requests to another server while IPv4 appears perfectly healthy.<\/p>\n<p>I normally start in the terminal rather than in a hosting panel:<\/p>\n<pre><code>dig +short example.com A\ndig +short example.com AAAA\ndig +short www.example.com A<\/code><\/pre>\n<p>The first command should show the VPS&#8217;s IPv4 address. The second should show the IPv6 address, if one is configured. An empty result can mean the record is missing or that the DNS change has not reached the resolver you are using yet. Depending on the TTL, you may need to wait. Running Certbot repeatedly will not make DNS update faster.<\/p>\n<p>Next, see what is listening:<\/p>\n<pre><code>sudo ss -tulpn | grep -E ':(80|443)b'\nsudo ufw status verbose<\/code><\/pre>\n<p>Let&#8217;s Encrypt needs to reach TCP port 80 from the internet when you use HTTP-01 validation. Port 443 must also be reachable for normal HTTPS traffic. With UFW, this profile opens both ports:<\/p>\n<pre><code>sudo ufw allow 'Nginx Full'\nsudo ufw status<\/code><\/pre>\n<p>This changes the firewall on the VPS. If your provider has a security group, or you also use nftables or an external firewall, allow the same traffic there. Opening a port in UFW does not open a port blocked by the provider&#8217;s network rules.<\/p>\n<h2>Install Certbot<\/h2>\n<p>On Debian and Ubuntu, Certbot and its web-server plugins are available through the distribution package manager on many supported releases. Some Ubuntu installations use Snap instead. I generally prefer <code>apt<\/code> on Debian systems so package management stays consistent with the rest of the server, but check the instructions for the operating system you actually run.<\/p>\n<p>First identify the operating system and the web server:<\/p>\n<pre><code>cat \/etc\/os-release\nnginx -v\napache2 -v<\/code><\/pre>\n<p>If one of the web-server commands is not installed, it will print an error. That is fine; it is still useful information.<\/p>\n<p>For Nginx on Debian or Ubuntu:<\/p>\n<pre><code>sudo apt update\nsudo apt install certbot python3-certbot-nginx<\/code><\/pre>\n<p>For Apache, install its plugin instead:<\/p>\n<pre><code>sudo apt install certbot python3-certbot-apache<\/code><\/pre>\n<p>The plugins can read your virtual host configuration and make certificate installation easier. I still back up the configuration before allowing an automated tool to edit a production server. A small mistake can affect an unexpected site, especially on a VPS hosting several domains.<\/p>\n<pre><code>sudo cp -a \/etc\/nginx \/etc\/nginx.backup.$(date +%F)\nsudo nginx -t<\/code><\/pre>\n<p>You should see <code>syntax is ok<\/code> and <code>test is successful<\/code> in the <code>nginx -t<\/code> output. If you do not, stop there and fix the configuration first.<\/p>\n<h2>Use Let&#8217;s Encrypt with Nginx<\/h2>\n<p>Make sure the domain is present in an Nginx server block. This is a small HTTP-only example:<\/p>\n<pre><code>server {\n    listen 80;\n    listen [::]:80;\n    server_name example.com www.example.com;\n\n    root \/var\/www\/example.com\/public;\n    index index.html index.php;\n\n    location \/ {\n        try_files $uri $uri\/ =404;\n    }\n}<\/code><\/pre>\n<p>Save it as, for example, <code>\/etc\/nginx\/sites-available\/example.com<\/code>, then enable it:<\/p>\n<pre><code>sudo ln -s \/etc\/nginx\/sites-available\/example.com \/etc\/nginx\/sites-enabled\/example.com\nsudo nginx -t\nsudo systemctl reload nginx<\/code><\/pre>\n<p><code>reload<\/code> makes Nginx reread its configuration without dropping existing connections. Before requesting the certificate, open the domain over HTTP and confirm that the expected site appears. If you see the default Nginx page, the hostname match is not correct yet.<\/p>\n<p>Checking the hostname before changing Nginx in production is not theoretical advice for me. I once changed a server I thought was staging and found out, rather late, that it was production. That was the day I started using a red shell prompt on production systems. I still run <code>hostname<\/code> before editing a configuration file.<\/p>\n<p>Now run Certbot with the Nginx plugin:<\/p>\n<pre><code>sudo certbot --nginx -d example.com -d www.example.com<\/code><\/pre>\n<p>Certbot asks for an email address, acceptance of the terms, and whether HTTP traffic should be redirected to HTTPS. For a normal public site, I choose the redirect. For an application behind a proxy, I first check how <code>X-Forwarded-Proto<\/code> is handled so I do not create a redirect loop.<\/p>\n<p>After a successful run, inspect the certificate:<\/p>\n<pre><code>sudo ls -l \/etc\/letsencrypt\/live\/example.com\/\nsudo certbot certificates<\/code><\/pre>\n<p>The files under <code>live<\/code> are symbolic links to the current certificate version, not separate permanent copies. Pointing Nginx at these paths means you do not need to edit the configuration after every renewal:<\/p>\n<pre><code>ssl_certificate \/etc\/letsencrypt\/live\/example.com\/fullchain.pem;\nssl_certificate_key \/etc\/letsencrypt\/live\/example.com\/privkey.pem;<\/code><\/pre>\n<p>Certbot normally adds these directives itself. If you configure them manually, use <code>fullchain.pem<\/code> for the certificate. Using only <code>cert.pem<\/code> can cause intermediate-chain problems with some clients.<\/p>\n<h2>Apache takes a different plugin<\/h2>\n<p>On an Apache VPS, the process is similar. Confirm that the domain is defined in the virtual host, then run:<\/p>\n<pre><code>sudo certbot --apache -d example.com -d www.example.com<\/code><\/pre>\n<p>Certbot can add the SSL configuration to the Apache virtual host and redirect HTTP traffic to HTTPS. Test the configuration after the change:<\/p>\n<pre><code>sudo apachectl configtest\nsudo systemctl reload apache2<\/code><\/pre>\n<p><code>Syntax OK<\/code> means the configuration is syntactically valid. If your application runs on PHP-FPM, Node.js, or Docker behind Apache, the certificate usually belongs on the public-facing Apache or Nginx layer, not inside the application itself.<\/p>\n<p>For a Docker VPS, decide where the reverse proxy will live before adding containers. Instead of distributing separate certificates to every container, let Nginx Proxy Manager, Traefik, or Nginx on the host terminate TLS for incoming traffic. The port-checking approach in <a>How to Install Docker on a VPS and Run Your First Container<\/a> is useful here, particularly before the first container starts competing for ports.<\/p>\n<h2>Check the HTTPS redirect<\/h2>\n<p>After installation, check the certificate endpoint and the redirect from the terminal:<\/p>\n<pre><code>curl -I http:\/\/example.com\ncurl -I https:\/\/example.com<\/code><\/pre>\n<p>The first command will usually return <code>301<\/code> or <code>308<\/code> with a header such as <code>Location: https:\/\/example.com\/<\/code>. A <code>200<\/code> from the second request is a good sign, although an application may return a different status on its login page. The useful checks are that the TLS handshake succeeds and that the intended certificate is being served.<\/p>\n<p>OpenSSL can show the certificate details:<\/p>\n<pre><code>echo | openssl s_client -connect example.com:443 -servername example.com 2&gt;\/dev\/null | openssl x509 -noout -subject -issuer -dates<\/code><\/pre>\n<p>The <code>-servername<\/code> option matters. When several domains share one IP address, testing without SNI may show the default virtual host&#8217;s certificate instead. Check <code>notBefore<\/code>, <code>notAfter<\/code>, <code>subject<\/code>, and <code>issuer<\/code> in the output.<\/p>\n<h2>Renewal automation is the part that matters<\/h2>\n<p>Let&#8217;s Encrypt certificates are short-lived, so installing one on a VPS is only half the job. Test renewal before the expiry date becomes somebody&#8217;s emergency:<\/p>\n<pre><code>systemctl list-timers | grep certbot\nsystemctl status certbot.timer\nsudo certbot renew --dry-run<\/code><\/pre>\n<p><code>--dry-run<\/code> tests the renewal path without replacing the live certificate. A successful run is a good indication that DNS validation, port access, and the web-server reload all work. If it fails, fix it now. The day a certificate expires is a poor time to discover that a timer has been disabled.<\/p>\n<p>After renewal, confirm that Nginx or Apache loads the new certificate. The files can change while the running process continues serving the old certificate from memory. Certbot normally handles the reload, but a custom setup may need a deploy hook:<\/p>\n<pre><code>sudo install -d -m 0755 \/etc\/letsencrypt\/renewal-hooks\/deploy\nsudo vim \/etc\/letsencrypt\/renewal-hooks\/deploy\/reload-nginx.sh<\/code><\/pre>\n<pre><code>#!\/bin\/sh\nsystemctl reload nginx<\/code><\/pre>\n<p>Make the hook executable:<\/p>\n<pre><code>sudo chmod 0755 \/etc\/letsencrypt\/renewal-hooks\/deploy\/reload-nginx.sh<\/code><\/pre>\n<p>The hook runs during Certbot&#8217;s deploy phase, after a certificate has actually been renewed. I would rather connect the reload to that renewal flow than add a cron line that reloads Nginx unnecessarily every day.<\/p>\n<p>Do not assume renewal works because a timer exists. I monitor the result of <code>certbot renew --dry-run<\/code> and check the served certificate separately. An availability alarm that only checks the HTTP status can report the site as healthy while the certificate is already expired.<\/p>\n<h2>HTTP-01 and DNS-01 validation<\/h2>\n<p>Certbot uses challenge methods to prove that you control the domain. For a standard Nginx setup, HTTP-01 is usually the simplest. Let&#8217;s Encrypt tries to fetch a temporary file under <code>http:\/\/example.com\/.well-known\/acme-challenge\/...<\/code>. Completely blocking port 80, or rejecting every HTTP request in the application layer, can break validation.<\/p>\n<p>HTTP-01 does not support wildcard certificates. For <code>*.example.com<\/code>, use DNS-01 instead. Certbot plugins for DNS providers can create the required TXT record through an API. If you use the Cloudflare DNS plugin and store its API credentials in a file, restrict the permissions:<\/p>\n<pre><code>sudo chmod 0600 \/root\/.secrets\/certbot\/cloudflare.ini<\/code><\/pre>\n<p>Do not put the token in shell history or in a playbook that everyone can read. The DNS API token should have only the permission to create and delete TXT records in the relevant zone. Small permissions matter when a token leaks.<\/p>\n<h2>Common failures and how I diagnose them<\/h2>\n<h3>Timeout or connection refused<\/h3>\n<p>This usually points to network access, not to the certificate. First confirm that DNS returns the right IP, then check port 80 in both the VPS firewall and the provider security group. Nginx listening only on <code>127.0.0.1:80<\/code> will also prevent external validation.<\/p>\n<pre><code>sudo ss -ltnp | grep ':80'\ncurl -4 -I http:\/\/example.com\ncurl -6 -I http:\/\/example.com<\/code><\/pre>\n<p>If IPv4 works but IPv6 does not, and the domain has a bad <code>AAAA<\/code> record, correct DNS. I hit this during a VPS migration: I updated the A record and missed the old AAAA record. Clients using IPv6 continued visiting the previous server.<\/p>\n<h3>Too many redirects<\/h3>\n<p>If you use a proxy such as Cloudflare, its SSL mode must agree with the origin server&#8217;s HTTPS configuration. A proxy can accept HTTPS externally and connect to the VPS over HTTP, while the VPS redirects that request back to HTTPS. That combination creates a loop. Make sure the application interprets proxy headers correctly.<\/p>\n<h3>Rate-limit errors<\/h3>\n<p>Do not repeat failed requests over and over. Let&#8217;s Encrypt applies validation and certificate limits in production. Test the flow against its staging environment first:<\/p>\n<pre><code>sudo certbot certonly --staging --nginx -d example.com<\/code><\/pre>\n<p>Staging certificates are not trusted by browsers; they are only for testing the installation flow. After testing, check that the staging certificate has not been left in the live configuration.<\/p>\n<h3>The web server cannot reload<\/h3>\n<p>Certbot may obtain the certificate successfully while the Nginx reload fails because of an unrelated configuration error. Start with:<\/p>\n<pre><code>sudo nginx -t\nsudo journalctl -u nginx -n 80 --no-pager<\/code><\/pre>\n<p>Restarting before reading the logs was one of my early-career mistakes. When certificate installation goes wrong, I test the configuration and inspect the relevant logs before restarting anything. That avoids cutting existing connections for no reason.<\/p>\n<h2>Harden TLS carefully<\/h2>\n<p>Certbot&#8217;s defaults are a sensible starting point for most sites. Before writing a custom cipher list, understand the application&#8217;s client requirements. If you do not need support for old devices, TLS 1.2 and TLS 1.3 are a reasonable baseline:<\/p>\n<pre><code>ssl_protocols TLSv1.2 TLSv1.3;<\/code><\/pre>\n<p>Do not rush into HSTS. The <code>Strict-Transport-Security<\/code> header tells browsers to use HTTPS for the domain. Do not enable <code>includeSubDomains<\/code>, and especially not <code>preload<\/code>, until every subdomain is ready for HTTPS. A forgotten subdomain can become unexpectedly difficult to reach.<\/p>\n<p>Before enabling HSTS, check the site&#8217;s assets, API endpoints, and third-party content. Images, JavaScript, or fonts loaded over HTTP will create mixed-content warnings. The browser&#8217;s developer console is a quick way to find these references.<\/p>\n<h2>A certificate is not server security<\/h2>\n<p>An SSL certificate encrypts the connection between the client and the server and helps verify the domain. It does not mean that the operating system is patched, SSH is safely configured, or the application has no vulnerabilities.<\/p>\n<p>Handle firewall rules, updates, separate users, SSH keys, and intrusion monitoring as separate tasks. The baseline checks in <a>10 Essential Steps to Secure and Harden Your Linux Server<\/a> are a useful starting point, but adjust them to the risks of your own VPS. Changing the SSH port is not security by itself; without log monitoring, it mostly moves the noise somewhere else.<\/p>\n<p>When measuring performance after enabling HTTPS, separate the TLS handshake, HTTP version, and application response time. Arguing over TTFB one millisecond at a time while leaving caching unconfigured feels like polishing the thermometer with the window open. If you are comparing HTTP\/2 and HTTP\/3, <a>Demystifying Internet Protocols: From TCP\/IP to HTTP\/3<\/a> gives the network-layer context you need.<\/p>\n<h2>My post-installation checklist<\/h2>\n<ul>\n<li>Do the A and, if applicable, AAAA records point to the correct VPS?<\/li>\n<li>Are TCP ports 80 and 443 open in both the operating system firewall and the provider security group?<\/li>\n<li>Is Nginx or Apache loading the intended virtual host?<\/li>\n<li>Does <code>certbot certificates<\/code> show the right domain and expiry date?<\/li>\n<li>Does <code>certbot renew --dry-run<\/code> complete successfully?<\/li>\n<li>Does the web server reload after renewal and serve the new certificate?<\/li>\n<li>Are HTTP requests redirected to HTTPS as expected?<\/li>\n<li>Do HSTS and proxy settings avoid redirect loops and mixed content?<\/li>\n<\/ul>\n<p>Once the installation is complete, add certificate expiry to your monitoring. Uptime Kuma can check site availability, while Prometheus or a dedicated exporter can track certificate expiry. In my Proxmox lab, one of my first tests was exactly this: I tested renewal on a staging domain before moving to a customer domain.<\/p>\n<p>Let&#8217;s Encrypt can be installed with a few commands. The real test is the whole path, from DNS to the renewal alert. A certificate working today is not enough. I want to know that it will still work two months from now, during a night shift, without somebody having to intervene manually.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A practical guide to installing an SSL certificate on a VPS with Let&#8217;s Encrypt, including DNS checks, Certbot, Nginx, Apache, validation, renewal, and troubleshooting.<\/p>\n","protected":false},"author":2,"featured_media":308,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[9],"tags":[1292,1286,1295,1283,1289,1280,1298,29],"class_list":["post-310","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-how-to","tag-apache-en","tag-certbot-en","tag-https-en","tag-lets-encrypt-en","tag-nginx-en","tag-ssl-certificate","tag-tls","tag-vps"],"lang":"en","translations":{"en":310,"tr":309},"pll_sync_post":[],"_links":{"self":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/310","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/comments?post=310"}],"version-history":[{"count":1,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/310\/revisions"}],"predecessor-version":[{"id":312,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/posts\/310\/revisions\/312"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/media\/308"}],"wp:attachment":[{"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/media?parent=310"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/categories?post=310"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.vps.tc\/blog\/wp-json\/wp\/v2\/tags?post=310"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}