<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: rubendob</title>
    <description>The latest articles on DEV Community by rubendob (@rubendob).</description>
    <link>https://dev.to/rubendob</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F810324%2F0115a221-701c-4995-9988-5ad01d16471a.jpeg</url>
      <title>DEV Community: rubendob</title>
      <link>https://dev.to/rubendob</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rubendob"/>
    <language>en</language>
    <item>
      <title>Automating SSL with Let’s Encrypt on AWS</title>
      <dc:creator>rubendob</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:21:06 +0000</pubDate>
      <link>https://dev.to/rubendob/automating-ssl-with-lets-encrypt-on-aws-18i9</link>
      <guid>https://dev.to/rubendob/automating-ssl-with-lets-encrypt-on-aws-18i9</guid>
      <description>&lt;p&gt;For quite some time, the SSL certificate for my WordPress site running on AWS worked in a fairly traditional way: I purchased the certificate, renewed it manually once a year, and installed it on the server. It worked, but there was an obvious problem: &lt;strong&gt;it depended on periodic manual intervention&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In addition, my architecture had evolved, and this approach to certificate management no longer fitted particularly well.&lt;/p&gt;

&lt;p&gt;In this post, we are going to see how to automate an SSL certificate using Let’s Encrypt on AWS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Initial architecture
&lt;/h2&gt;

&lt;p&gt;The blog is deployed on &lt;strong&gt;AWS Elastic Beanstalk&lt;/strong&gt;, using an EC2 instance in &lt;code&gt;SingleInstance&lt;/code&gt; mode.&lt;/p&gt;

&lt;p&gt;In front of Elastic Beanstalk, I have a &lt;strong&gt;CloudFront&lt;/strong&gt; distribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet
   |
   v
CloudFront
   |
   | HTTPS
   v
Elastic Beanstalk
   |
   v
Nginx
   |
   v
WordPress
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are actually &lt;strong&gt;two different TLS connections&lt;/strong&gt; here.&lt;/p&gt;

&lt;p&gt;The first one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client -&amp;gt; CloudFront
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;uses a certificate from &lt;strong&gt;AWS Certificate Manager (ACM)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That certificate was already fully automated by AWS and did not require any changes.&lt;/p&gt;

&lt;p&gt;The second one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CloudFront -&amp;gt; Elastic Beanstalk / Nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;used another certificate.&lt;/p&gt;

&lt;p&gt;That was the certificate I purchased through DonDominio and that was issued by Sectigo.&lt;/p&gt;

&lt;p&gt;Nginx loaded it from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssl_certificate     /etc/pki/tls/certs/ssl-bundle.crt;
ssl_certificate_key /etc/pki/tls/certs/server.key;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The files were stored in a private S3 bucket, and during the Elastic Beanstalk deployment a hook downloaded both of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S3
 |
 v
Elastic Beanstalk prebuild
 |
 v
/etc/pki/tls/certs/
 |
 v
Nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism worked, but every renewal required purchasing the new certificate, downloading it, preparing the certificate chain, uploading it to S3, and deploying it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second problem: the EC2 instance is Spot
&lt;/h2&gt;

&lt;p&gt;There was another important detail. The instance used by Elastic Beanstalk is &lt;strong&gt;Spot&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This means that the EC2 instance must be treated as ephemeral infrastructure: AWS may replace it.&lt;/p&gt;

&lt;p&gt;Therefore, installing Certbot directly on that instance and letting it handle renewals was not a solution I particularly liked.&lt;/p&gt;

&lt;p&gt;It could be done, but it would require persisting Certbot's state outside the machine and restoring it whenever the instance was replaced.&lt;/p&gt;

&lt;p&gt;I preferred to completely &lt;strong&gt;decouple&lt;/strong&gt; certificate renewal from the EC2 lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goal
&lt;/h2&gt;

&lt;p&gt;The goal was to reach an architecture where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Let’s Encrypt issued the certificate.&lt;/li&gt;
&lt;li&gt;Validation was completely automated.&lt;/li&gt;
&lt;li&gt;No permanent AWS credentials were stored in GitHub.&lt;/li&gt;
&lt;li&gt;The certificate survived Spot instance replacements.&lt;/li&gt;
&lt;li&gt;A renewal automatically updated Nginx.&lt;/li&gt;
&lt;li&gt;A new EC2 instance could retrieve the latest available certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The resulting architecture would look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Actions
      |
      | OIDC
      v
     AWS
      |
      +------&amp;gt; Route53
      |         DNS-01
      |
      +------&amp;gt; Let's Encrypt
      |
      +------&amp;gt; S3
      |         |
      |         +-- ACME state
      |         +-- certificate
      |
      +------&amp;gt; SSM Run Command
                  |
                  v
              EC2 / Nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  DNS-01 validation with Route53
&lt;/h2&gt;

&lt;p&gt;To issue the certificate, I decided to use the ACME &lt;strong&gt;DNS-01 challenge&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The certificate covers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rubenortiz.es
www.rubenortiz.es
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since the DNS zone is hosted in Route53, Certbot can temporarily create the necessary TXT records to prove to Let’s Encrypt that I control the domain.&lt;/p&gt;

&lt;p&gt;The workflow uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;certbot
+
certbot-dns-route53
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way, I do not need to expose any special URL in WordPress or modify HTTP traffic in order to perform the validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Actions and AWS OIDC
&lt;/h2&gt;

&lt;p&gt;The renewal process runs from &lt;strong&gt;GitHub Actions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of storing an &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; as secrets, the workflow uses &lt;strong&gt;OIDC&lt;/strong&gt; to temporarily assume an IAM Role.&lt;/p&gt;

&lt;p&gt;The flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Actions
      |
      | OIDC token
      v
AWS STS
      |
      v
IAM Role
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That role only receives the permissions required to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;manage the DNS challenge in Route53;&lt;/li&gt;
&lt;li&gt;read and write certificates in S3;&lt;/li&gt;
&lt;li&gt;locate the active EC2 instance;&lt;/li&gt;
&lt;li&gt;execute commands through AWS Systems Manager.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Persisting Certbot state
&lt;/h2&gt;

&lt;p&gt;GitHub Actions runners are also ephemeral.&lt;/p&gt;

&lt;p&gt;Each execution starts on a new machine.&lt;/p&gt;

&lt;p&gt;Certbot, however, stores important information under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/etc/letsencrypt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This directory contains ACME accounts, renewal configurations, previous certificates, and other required information.&lt;/p&gt;

&lt;p&gt;To avoid losing this state, I decided to store it in S3.&lt;/p&gt;

&lt;p&gt;Instead of synchronizing the directory directly, I package it as a &lt;code&gt;tar.gz&lt;/code&gt;, because Certbot uses symbolic links between the &lt;code&gt;live&lt;/code&gt; and &lt;code&gt;archive&lt;/code&gt; directories.&lt;/p&gt;

&lt;p&gt;The result is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s3://mybucket/ssl/acme-state-prod/
    letsencrypt.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the beginning of the workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S3
 |
 v
restore ACME state
 |
 v
Certbot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and after Certbot runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Certbot
 |
 v
tar.gz
 |
 v
S3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The active certificate
&lt;/h2&gt;

&lt;p&gt;The certificate that must be consumed by the instances is kept separate from Certbot's internal state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s3://mybucket/ssl/current/
    fullchain.pem
    privkey.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fullchain.pem&lt;/code&gt; contains both the domain certificate and the intermediate certificate chain required by Nginx.&lt;/p&gt;

&lt;p&gt;The private key is stored as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;privkey.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Updating the EC2 instance using SSM
&lt;/h2&gt;

&lt;p&gt;Generating and storing the certificate was only part of the problem.&lt;/p&gt;

&lt;p&gt;I still needed the instance running WordPress to actually start using it.&lt;/p&gt;

&lt;p&gt;For that, I use &lt;strong&gt;AWS Systems Manager Run Command&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;GitHub Actions first locates the active Elastic Beanstalk instance using its tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws ec2 describe-instances
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws ssm send-command
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;using the AWS-managed document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AWS-RunShellScript
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remote command roughly performs the following operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;create certificate directory
        |
        v
download fullchain.pem from S3
        |
        v
download privkey.pem from S3
        |
        v
nginx -t
        |
        v
systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before reloading Nginx, the following command is always executed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nginx -t
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the Nginx configuration is invalid, the process fails before reloading the service.&lt;/p&gt;

&lt;h2&gt;
  
  
  New Nginx configuration
&lt;/h2&gt;

&lt;p&gt;I also changed the paths used by Nginx to make it clear that these certificates belong to the new mechanism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssl_certificate     /etc/pki/tls/certs/letsencrypt/fullchain.pem;
ssl_certificate_key /etc/pki/tls/certs/letsencrypt/privkey.pem;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first real certificate issuance produced a certificate similar to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subject=CN=www.rubenortiz.es
issuer=C=US, O=Let's Encrypt, CN=YE1

DNS:rubenortiz.es
DNS:www.rubenortiz.es
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After reloading Nginx, I checked directly against the local Nginx listener:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl s_client &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-connect&lt;/span&gt; localhost:443 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-servername&lt;/span&gt; www.rubenortiz.es
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and confirmed that the origin was already serving the Let’s Encrypt certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens if AWS replaces the Spot instance?
&lt;/h2&gt;

&lt;p&gt;This was one of the most important parts of the design.&lt;/p&gt;

&lt;p&gt;The renewal process does not depend on EC2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Actions -&amp;gt; Let's Encrypt -&amp;gt; S3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And S3 acts as the persistent source for the certificate.&lt;/p&gt;

&lt;p&gt;The Elastic Beanstalk &lt;code&gt;prebuild&lt;/code&gt; hook was also modified to download:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssl/current/fullchain.pem
ssl/current/privkey.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Therefore, if AWS removes the current Spot instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Old EC2
    X
    |
    v
New EC2
    |
    v
Elastic Beanstalk prebuild
    |
    v
S3 /ssl/current/
    |
    v
Nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the new instance automatically retrieves the current certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automatic renewal
&lt;/h2&gt;

&lt;p&gt;Finally, the workflow no longer depends on pushes and instead runs on a GitHub Actions cron schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;

  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means it runs every Monday and can also be triggered manually.&lt;/p&gt;

&lt;p&gt;Certbot runs with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--keep-until-expiring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;so running the workflow weekly &lt;strong&gt;does not mean issuing a new certificate every week&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;As long as the current certificate is still valid for long enough, Certbot keeps the existing one.&lt;/p&gt;

&lt;p&gt;This is useful because it provides several opportunities to renew the certificate before it reaches its expiration date.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Actions recipe
&lt;/h2&gt;

&lt;p&gt;In this case, in order to determine which EC2 instance currently exists, I retrieve the information using the EB-related instance tags.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Renew Let's Encrypt Certificate&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;AWS_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;AWS_REGION&amp;gt;"&lt;/span&gt;
  &lt;span class="na"&gt;AWS_ACCOUNT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;AWS_ACCOUNT_ID&amp;gt;"&lt;/span&gt;
  &lt;span class="na"&gt;AWS_OIDC_ROLE_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;OIDC_ROLE_NAME&amp;gt;"&lt;/span&gt;

  &lt;span class="na"&gt;LETSENCRYPT_EMAIL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;EMAIL_ADDRESS&amp;gt;"&lt;/span&gt;

  &lt;span class="na"&gt;CERT_DOMAIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;www.example.com"&lt;/span&gt;
  &lt;span class="na"&gt;CERT_APEX_DOMAIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;example.com"&lt;/span&gt;

  &lt;span class="na"&gt;CERT_BUCKET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;PRIVATE_S3_BUCKET&amp;gt;"&lt;/span&gt;
  &lt;span class="na"&gt;ACME_STATE_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;certificates/acme-state/state.tar.gz"&lt;/span&gt;
  &lt;span class="na"&gt;CERT_CURRENT_PREFIX&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;certificates/current"&lt;/span&gt;

  &lt;span class="na"&gt;EB_ENV_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;ELASTIC_BEANSTALK_ENVIRONMENT&amp;gt;"&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;letsencrypt-production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Renew production certificate&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS credentials&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.AWS_OIDC_ROLE_NAME }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.AWS_REGION }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Certbot&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;python -m venv .venv&lt;/span&gt;
          &lt;span class="s"&gt;.venv/bin/pip install --upgrade pip&lt;/span&gt;
          &lt;span class="s"&gt;.venv/bin/pip install certbot certbot-dns-route53&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restore Certbot state from S3&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;mkdir -p "${{ runner.temp }}/letsencrypt"&lt;/span&gt;

          &lt;span class="s"&gt;if aws s3 ls \&lt;/span&gt;
            &lt;span class="s"&gt;"s3://${{ env.CERT_BUCKET }}/${{ env.ACME_STATE_KEY }}" \&lt;/span&gt;
            &lt;span class="s"&gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1; then&lt;/span&gt;

            &lt;span class="s"&gt;aws s3 cp \&lt;/span&gt;
              &lt;span class="s"&gt;"s3://${{ env.CERT_BUCKET }}/${{ env.ACME_STATE_KEY }}" \&lt;/span&gt;
              &lt;span class="s"&gt;"${{ runner.temp }}/letsencrypt.tar.gz"&lt;/span&gt;

            &lt;span class="s"&gt;tar -xzf "${{ runner.temp }}/letsencrypt.tar.gz" \&lt;/span&gt;
              &lt;span class="s"&gt;-C "${{ runner.temp }}/letsencrypt"&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Issue Let's Encrypt certificate&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;.venv/bin/certbot certonly \&lt;/span&gt;
            &lt;span class="s"&gt;--dns-route53 \&lt;/span&gt;
            &lt;span class="s"&gt;--config-dir "${{ runner.temp }}/letsencrypt" \&lt;/span&gt;
            &lt;span class="s"&gt;--work-dir "${{ runner.temp }}/letsencrypt-work" \&lt;/span&gt;
            &lt;span class="s"&gt;--logs-dir "${{ runner.temp }}/letsencrypt-logs" \&lt;/span&gt;
            &lt;span class="s"&gt;--non-interactive \&lt;/span&gt;
            &lt;span class="s"&gt;--agree-tos \&lt;/span&gt;
            &lt;span class="s"&gt;--keep-until-expiring \&lt;/span&gt;
            &lt;span class="s"&gt;--email "${{ env.LETSENCRYPT_EMAIL }}" \&lt;/span&gt;
            &lt;span class="s"&gt;-d "${{ env.CERT_DOMAIN }}" \&lt;/span&gt;
            &lt;span class="s"&gt;-d "${{ env.CERT_APEX_DOMAIN }}"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate certificate&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;openssl x509 \&lt;/span&gt;
            &lt;span class="s"&gt;-in "${{ runner.temp }}/letsencrypt/live/${{ env.CERT_DOMAIN }}/fullchain.pem" \&lt;/span&gt;
            &lt;span class="s"&gt;-noout \&lt;/span&gt;
            &lt;span class="s"&gt;-subject \&lt;/span&gt;
            &lt;span class="s"&gt;-issuer \&lt;/span&gt;
            &lt;span class="s"&gt;-dates \&lt;/span&gt;
            &lt;span class="s"&gt;-ext subjectAltName&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Persist Certbot state to S3&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;tar -czf "${{ runner.temp }}/letsencrypt.tar.gz" \&lt;/span&gt;
            &lt;span class="s"&gt;-C "${{ runner.temp }}/letsencrypt" .&lt;/span&gt;

          &lt;span class="s"&gt;aws s3 cp \&lt;/span&gt;
            &lt;span class="s"&gt;"${{ runner.temp }}/letsencrypt.tar.gz" \&lt;/span&gt;
            &lt;span class="s"&gt;"s3://${{ env.CERT_BUCKET }}/${{ env.ACME_STATE_KEY }}"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload active certificate to S3&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;aws s3 cp \&lt;/span&gt;
            &lt;span class="s"&gt;"${{ runner.temp }}/letsencrypt/live/${{ env.CERT_DOMAIN }}/fullchain.pem" \&lt;/span&gt;
            &lt;span class="s"&gt;"s3://${{ env.CERT_BUCKET }}/${{ env.CERT_CURRENT_PREFIX }}/fullchain.pem"&lt;/span&gt;

          &lt;span class="s"&gt;aws s3 cp \&lt;/span&gt;
            &lt;span class="s"&gt;"${{ runner.temp }}/letsencrypt/live/${{ env.CERT_DOMAIN }}/privkey.pem" \&lt;/span&gt;
            &lt;span class="s"&gt;"s3://${{ env.CERT_BUCKET }}/${{ env.CERT_CURRENT_PREFIX }}/privkey.pem"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Download the new SSL certificate to the EC2 instance&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;

          &lt;span class="s"&gt;INSTANCE_ID=$(aws ec2 describe-instances \&lt;/span&gt;
            &lt;span class="s"&gt;--filters \&lt;/span&gt;
              &lt;span class="s"&gt;"Name=tag:elasticbeanstalk:environment-name,Values=${{ env.EB_ENV_NAME }}" \&lt;/span&gt;
              &lt;span class="s"&gt;"Name=instance-state-name,Values=running" \&lt;/span&gt;
            &lt;span class="s"&gt;--query 'Reservations[].Instances[].InstanceId' \&lt;/span&gt;
            &lt;span class="s"&gt;--output text)&lt;/span&gt;

          &lt;span class="s"&gt;if [ -z "$INSTANCE_ID" ]; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "No running EC2 instance found"&lt;/span&gt;
            &lt;span class="s"&gt;exit 1&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;

          &lt;span class="s"&gt;echo "EC2 instance found"&lt;/span&gt;

          &lt;span class="s"&gt;COMMAND_ID=$(aws ssm send-command \&lt;/span&gt;
            &lt;span class="s"&gt;--instance-ids "$INSTANCE_ID" \&lt;/span&gt;
            &lt;span class="s"&gt;--document-name "AWS-RunShellScript" \&lt;/span&gt;
            &lt;span class="s"&gt;--comment "Deploy Let's Encrypt certificate" \&lt;/span&gt;
            &lt;span class="s"&gt;--parameters 'commands=[&lt;/span&gt;
              &lt;span class="s"&gt;"mkdir -p /etc/pki/tls/certs/example",&lt;/span&gt;
              &lt;span class="s"&gt;"aws s3 cp s3://${{ env.CERT_BUCKET }}/${{ env.CERT_CURRENT_PREFIX }}/fullchain.pem /etc/pki/tls/certs/example/fullchain.pem",&lt;/span&gt;
              &lt;span class="s"&gt;"aws s3 cp s3://${{ env.CERT_BUCKET }}/${{ env.CERT_CURRENT_PREFIX }}/privkey.pem /etc/pki/tls/certs/example/privkey.pem",&lt;/span&gt;
              &lt;span class="s"&gt;"chmod 600 /etc/pki/tls/certs/example/privkey.pem",&lt;/span&gt;
              &lt;span class="s"&gt;"nginx -t",&lt;/span&gt;
              &lt;span class="s"&gt;"systemctl reload nginx",&lt;/span&gt;
              &lt;span class="s"&gt;"openssl x509 -in /etc/pki/tls/certs/example/fullchain.pem -noout -subject -issuer -dates"&lt;/span&gt;
            &lt;span class="s"&gt;]' \&lt;/span&gt;
            &lt;span class="s"&gt;--query 'Command.CommandId' \&lt;/span&gt;
            &lt;span class="s"&gt;--output text)&lt;/span&gt;

          &lt;span class="s"&gt;echo "SSM command submitted"&lt;/span&gt;

          &lt;span class="s"&gt;set +e&lt;/span&gt;

          &lt;span class="s"&gt;aws ssm wait command-executed \&lt;/span&gt;
            &lt;span class="s"&gt;--command-id "$COMMAND_ID" \&lt;/span&gt;
            &lt;span class="s"&gt;--instance-id "$INSTANCE_ID"&lt;/span&gt;

          &lt;span class="s"&gt;WAIT_EXIT_CODE=$?&lt;/span&gt;

          &lt;span class="s"&gt;set -e&lt;/span&gt;

          &lt;span class="s"&gt;echo "SSM command result:"&lt;/span&gt;

          &lt;span class="s"&gt;aws ssm get-command-invocation \&lt;/span&gt;
            &lt;span class="s"&gt;--command-id "$COMMAND_ID" \&lt;/span&gt;
            &lt;span class="s"&gt;--instance-id "$INSTANCE_ID" \&lt;/span&gt;
            &lt;span class="s"&gt;--query '{&lt;/span&gt;
              &lt;span class="s"&gt;Status:Status,&lt;/span&gt;
              &lt;span class="s"&gt;StandardOutput:StandardOutputContent,&lt;/span&gt;
              &lt;span class="s"&gt;StandardError:StandardErrorContent&lt;/span&gt;
            &lt;span class="s"&gt;}'&lt;/span&gt;

          &lt;span class="s"&gt;if [ "$WAIT_EXIT_CODE" -ne 0 ]; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "SSM command failed or timed out"&lt;/span&gt;
            &lt;span class="s"&gt;exit 1&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Final architecture
&lt;/h2&gt;

&lt;p&gt;The final result looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                     +------------------+
                     |  GitHub Actions  |
                     |   weekly cron    |
                     +--------+---------+
                              |
                             OIDC
                              |
                              v
                     +------------------+
                     |       AWS        |
                     +------------------+
                         |          |
                         |          |
                  Route53 DNS-01    |
                         |          |
                         v          |
                  Let's Encrypt     |
                         |          |
                         +----+-----+
                              |
                              v
                    +--------------------+
                    |         S3         |
                    |                    |
                    | acme-state-prod/   |
                    | current/           |
                    +---------+----------+
                              |
                             SSM
                              |
                              v
                     +------------------+
                     | Elastic Beanstalk|
                     |      EC2 Spot    |
                     +--------+---------+
                              |
                              v
                            Nginx
                              |
                              v
                          WordPress
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CloudFront continues to use ACM for the public-facing certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User
  |
  | HTTPS / ACM
  |
CloudFront
  |
  | HTTPS / Let's Encrypt
  |
Nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;With this change, I removed the manual renewal of a certificate purchased from an external provider from the normal operational workflow.&lt;/p&gt;

&lt;p&gt;Now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Let’s Encrypt issues and renews the certificate.&lt;/li&gt;
&lt;li&gt;Route53 automatically handles the ACME challenge.&lt;/li&gt;
&lt;li&gt;GitHub Actions runs the process periodically.&lt;/li&gt;
&lt;li&gt;OIDC avoids permanent AWS credentials.&lt;/li&gt;
&lt;li&gt;S3 stores both the certificate and the ACME state.&lt;/li&gt;
&lt;li&gt;SSM updates the active EC2 instance.&lt;/li&gt;
&lt;li&gt;Nginx validates its configuration before being reloaded.&lt;/li&gt;
&lt;li&gt;A new Spot instance can rebuild itself using the certificate stored in S3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And perhaps most importantly, &lt;strong&gt;certificate management no longer depends on the lifetime of a specific machine or on me remembering once a year that the certificate needs to be renewed&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rubenortiz.es/category/seguridad/" rel="noopener noreferrer"&gt;https://www.rubenortiz.es/category/seguridad/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://community.letsencrypt.org/t/how-to-install-an-ssl-certificate-on-an-aws-ec2-instance/202218" rel="noopener noreferrer"&gt;https://community.letsencrypt.org/t/how-to-install-an-ssl-certificate-on-an-aws-ec2-instance/202218&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Terraform: Checksum Mismatch Between S3 and DynamoDB</title>
      <dc:creator>rubendob</dc:creator>
      <pubDate>Thu, 08 Oct 2026 09:47:09 +0000</pubDate>
      <link>https://dev.to/rubendob/terraform-checksum-mismatch-between-s3-and-dynamodb-2dg8</link>
      <guid>https://dev.to/rubendob/terraform-checksum-mismatch-between-s3-and-dynamodb-2dg8</guid>
      <description>&lt;p&gt;Recently, I ran into an interesting issue with a Terraform remote backend.&lt;/p&gt;

&lt;p&gt;Out of nowhere, every Terraform operation (&lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;refresh&lt;/code&gt;, or &lt;code&gt;apply&lt;/code&gt;) started failing with an error related to the remote state.&lt;/p&gt;

&lt;p&gt;Terraform was reporting a checksum mismatch between the state stored in S3 and the value stored in DynamoDB.&lt;/p&gt;

&lt;p&gt;The error looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: state data in S3 does not have the expected content

The checksum calculated for the state stored in S3 does not match the checksum
stored in DynamoDB.

Calculated checksum: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Stored checksum:     YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;In this setup, Terraform was using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An S3 bucket to store the &lt;code&gt;terraform.tfstate&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;A DynamoDB table to manage locking and metadata associated with the remote state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When Terraform accesses the backend, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads the state stored in S3.&lt;/li&gt;
&lt;li&gt;Calculates its checksum.&lt;/li&gt;
&lt;li&gt;Compares that value with the &lt;code&gt;Digest&lt;/code&gt; stored in DynamoDB.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In our case, those two values did not match.&lt;/p&gt;

&lt;p&gt;Because of this inconsistency, Terraform refused to continue in order to avoid working with a state that could potentially be corrupted or outdated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Initial Checks
&lt;/h2&gt;

&lt;p&gt;Before changing anything, we went through a few basic checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Confirm that no &lt;code&gt;terraform apply&lt;/code&gt; operation was currently running.&lt;/li&gt;
&lt;li&gt;Confirm that no CI/CD pipeline was using the same remote state.&lt;/li&gt;
&lt;li&gt;Reproduce the issue both locally and from the CI/CD pipelines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since the same error appeared regardless of where Terraform was executed, we could quickly rule out a problem with the local machine or the pipeline configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the Mismatch
&lt;/h2&gt;

&lt;p&gt;The next step was to inspect the DynamoDB table associated with the remote backend.&lt;/p&gt;

&lt;p&gt;Using the AWS CLI, we searched for the entry related to the affected state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws dynamodb scan &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; &amp;lt;lock-table&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Among the results, we found an entry similar to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"LockID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;state-path&amp;gt;-md5"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Digest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"S"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"old-value"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The value stored in &lt;code&gt;Digest&lt;/code&gt; matched exactly the checksum Terraform was reporting as incorrect.&lt;/p&gt;

&lt;p&gt;This confirmed that the current state stored in S3 and the digest registered in DynamoDB were out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Terraform was already telling us which checksum it expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Calculated checksum: correct-value
Stored checksum:     old-value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After verifying that the state file stored in S3 was valid, and confirming that no Terraform operation was currently running, we manually updated the &lt;code&gt;Digest&lt;/code&gt; field in DynamoDB so that it matched the checksum calculated by Terraform.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws dynamodb update-item &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; &amp;lt;lock-table&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--key&lt;/span&gt; &lt;span class="s1"&gt;'{"LockID":{"S":"&amp;lt;state-path&amp;gt;-md5"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--update-expression&lt;/span&gt; &lt;span class="s2"&gt;"SET Digest = :d"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--expression-attribute-values&lt;/span&gt; &lt;span class="s1"&gt;'{":d":{"S":"correct-value"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This type of manual change should be done carefully.&lt;/p&gt;

&lt;p&gt;You should not update the &lt;code&gt;Digest&lt;/code&gt; just to make the error disappear. First, make sure that the state stored in S3 is the correct one and that there are no concurrent Terraform operations running against the same backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;After updating the &lt;code&gt;Digest&lt;/code&gt;, we ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform was able to access the remote backend again, and the plan completed successfully.&lt;/p&gt;

&lt;p&gt;We did not need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recreate the state.&lt;/li&gt;
&lt;li&gt;Restore a previous state version.&lt;/li&gt;
&lt;li&gt;Modify the Terraform code.&lt;/li&gt;
&lt;li&gt;Recreate any infrastructure resources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The issue was simply a mismatch between the checksum stored in DynamoDB and the actual contents of the state file stored in S3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;When you see an error like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;state data in S3 does not have the expected content
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;it is easy to immediately assume that the &lt;code&gt;terraform.tfstate&lt;/code&gt; file is corrupted.&lt;/p&gt;

&lt;p&gt;However, that is not always the case.&lt;/p&gt;

&lt;p&gt;The state stored in S3 may be perfectly valid, while the inconsistency exists only in the &lt;code&gt;Digest&lt;/code&gt; value stored in DynamoDB.&lt;/p&gt;

&lt;p&gt;Before making any changes, I recommend checking the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm that no Terraform executions are currently running.&lt;/li&gt;
&lt;li&gt;Confirm that no CI/CD pipeline is using the same backend.&lt;/li&gt;
&lt;li&gt;Verify the state stored in S3.&lt;/li&gt;
&lt;li&gt;Inspect the corresponding entry in DynamoDB.&lt;/li&gt;
&lt;li&gt;Compare Terraform's &lt;code&gt;Calculated checksum&lt;/code&gt; with the &lt;code&gt;Stored checksum&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only after completing those checks should you consider manually correcting the &lt;code&gt;Digest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In this case, a few minutes spent understanding how the remote backend works saved us from wasting much more time debugging Terraform code that was not actually the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rubenortiz.es/tag/terraform/" rel="noopener noreferrer"&gt;https://www.rubenortiz.es/tag/terraform/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://stackoverflow.com/questions/57984714/error-refreshing-state-state-data-in-s3-does-not-have-the-expected-content" rel="noopener noreferrer"&gt;https://stackoverflow.com/questions/57984714/error-refreshing-state-state-data-in-s3-does-not-have-the-expected-content&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>terraform</category>
    </item>
    <item>
      <title>Protecting Credentials in Claude Code</title>
      <dc:creator>rubendob</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:23:50 +0000</pubDate>
      <link>https://dev.to/rubendob/protecting-credentials-in-claude-code-5agn</link>
      <guid>https://dev.to/rubendob/protecting-credentials-in-claude-code-5agn</guid>
      <description>&lt;h1&gt;
  
  
  Protecting Credentials in Claude Code
&lt;/h1&gt;

&lt;p&gt;There is a problem nobody really tells you about.&lt;/p&gt;

&lt;p&gt;If you use Claude Code for development, there is something you may not realize: it stores a complete history of your conversations as plain text on your machine. Depending on how you have used it, you may need to review how you handle credentials sooner rather than later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.claude/history.jsonl
~/.claude/file-history/
~/.claude/projects/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On top of that, everything you type into the chat is sent to Anthropic's servers for processing.&lt;/p&gt;

&lt;p&gt;That is how the service works — it is not a secret — but it is also something you may not think about while you are in the middle of a coding session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That is why knowing how to protect credentials when using Claude Code is critical.&lt;/strong&gt; Or, at the very least, understanding the security practices you should follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to protect credentials in Claude Code
&lt;/h2&gt;

&lt;p&gt;Before looking at detection and remediation, it is useful to understand which configuration files Claude Code uses and where they live.&lt;/p&gt;

&lt;p&gt;There are two main types.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;~/.claude/settings.json&lt;/code&gt;&lt;/strong&gt; — your personal configuration. It can contain permissions, MCP servers and environment configuration. This is the file you normally edit yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/Library/Application Support/ClaudeCode/managed-settings.json&lt;/code&gt;&lt;/strong&gt; — managed configuration for corporate environments. Administrators can use it to enforce security policies across an organization through MDM.&lt;/p&gt;

&lt;p&gt;If you work independently, this file probably does not exist on your machine.&lt;/p&gt;

&lt;p&gt;Both files can contain sensitive information if they are not configured carefully — especially &lt;code&gt;settings.json&lt;/code&gt;, where it is easy to accidentally end up with hardcoded credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Detection
&lt;/h2&gt;

&lt;p&gt;I started by scanning these files for credentials.&lt;/p&gt;

&lt;p&gt;I looked for common patterns such as AWS Access Key prefixes (&lt;code&gt;AKIA&lt;/code&gt;, &lt;code&gt;AKAR&lt;/code&gt;, etc.), GitHub tokens (&lt;code&gt;ghp_&lt;/code&gt;) and generic long-format keys.&lt;/p&gt;

&lt;p&gt;The result was not particularly surprising.&lt;/p&gt;

&lt;p&gt;When configuring connectors or integrations with certain providers, sensitive values can end up stored locally in files used by Claude.&lt;/p&gt;

&lt;p&gt;The bigger problem is that the information may not stay there. If you paste credentials into a conversation, that information is also sent to the model provider.&lt;/p&gt;

&lt;p&gt;That is the part that should make us think more carefully about how we use coding assistants.&lt;/p&gt;

&lt;p&gt;And I do not think this topic is discussed enough.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The rule I ended up with: treat Claude like pair programming over a video call with someone outside your company. You would not show them your keys on screen.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 2: Cleanup
&lt;/h2&gt;

&lt;p&gt;The first thing I did was revoke every credential that might have been exposed.&lt;/p&gt;

&lt;p&gt;If a key has appeared in plain text, I prefer to consider it compromised even if there is no evidence that somebody actually used it.&lt;/p&gt;

&lt;p&gt;Clear the chat history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/.claude/history.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove backups of files edited by Claude:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; ~/.claude/file-history/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clear conversations stored by project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find ~/.claude/projects/ &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.jsonl"&lt;/span&gt; &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;truncate&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 0 &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then review the configuration file.&lt;/p&gt;

&lt;p&gt;If there are tokens there, remove them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/.claude/settings.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Reconfigure using better security practices
&lt;/h2&gt;

&lt;p&gt;Once the credentials had been cleaned up and revoked, the goal was to configure everything again without allowing secret values to appear directly in the chat or in Claude configuration files.&lt;/p&gt;

&lt;h4&gt;
  
  
  Use a GitHub token with minimum privileges
&lt;/h4&gt;

&lt;p&gt;When configuring a GitHub token for Claude Code, do not enable every permission just because it is convenient.&lt;/p&gt;

&lt;p&gt;Apply the principle of least privilege and grant only the permissions you actually need.&lt;/p&gt;

&lt;p&gt;For example, when using a classic token with private repositories:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Purpose&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repo&lt;/td&gt;
&lt;td&gt;Read and write code, pull requests, issues and commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;workflow&lt;/td&gt;
&lt;td&gt;View and modify GitHub Actions workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;read:org&lt;/td&gt;
&lt;td&gt;Access private organization repositories&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;read:user&lt;/td&gt;
&lt;td&gt;User identification&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What you should avoid enabling unnecessarily: &lt;code&gt;admin:org&lt;/code&gt;, &lt;code&gt;delete_repo&lt;/code&gt;, &lt;code&gt;admin:repo_hook&lt;/code&gt;, &lt;code&gt;write:org&lt;/code&gt;, or other administrative scopes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The idea is the same as with any other credential.&lt;/p&gt;

&lt;p&gt;If a token is compromised, it should only be able to perform the exact operations you intended.&lt;/p&gt;

&lt;p&gt;The more permissions it has, the larger the blast radius.&lt;/p&gt;

&lt;p&gt;If your use case supports them, fine-grained tokens are usually a better option because they allow you to restrict access to specific repositories instead of granting access to the entire account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same principle applies to every other kind of token or key.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Use 1Password CLI as the source of truth
&lt;/h4&gt;

&lt;p&gt;One option is to use &lt;strong&gt;1Password&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It provides a CLI that can inject secrets directly into your shell environment without requiring you to store the actual values in your configuration files.&lt;/p&gt;

&lt;p&gt;Once everything is configured correctly, you will see something similar when Claude needs access to a specific item.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fosqoqlo2dt2l88slwbkk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fosqoqlo2dt2l88slwbkk.png" width="768" height="612"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Install the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;1password-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the account status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;op account list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use SSO, Okta or another identity provider, there is an additional consideration.&lt;/p&gt;

&lt;p&gt;Create the item in 1Password:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a new item of type &lt;strong&gt;API Credential&lt;/strong&gt; in the vault you use.&lt;/li&gt;
&lt;li&gt;Give it an easy-to-reference name, for example &lt;code&gt;GitHub PAT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Store the token in the credential field.&lt;/li&gt;
&lt;li&gt;If your Business account uses SSO, standard CLI authentication may not work directly.&lt;/li&gt;
&lt;li&gt;Open the 1Password desktop application.&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Settings → Developer → Connect with 1Password CLI&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enable it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The CLI will then authenticate through the desktop application, which already has your SSO session.&lt;/p&gt;

&lt;p&gt;No master password needs to be placed in scripts or configuration files.&lt;/p&gt;

&lt;p&gt;Then update your &lt;code&gt;.zshrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GITHUB_PERSONAL_ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;"op://MyVault/GitHub_PAT/credential"&lt;/span&gt; &lt;span class="nt"&gt;--no-newline&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_PERSONAL_ACCESS_TOKEN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify the environment variable without printing the complete secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$GITHUB_TOKEN&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-4&lt;/span&gt;

&lt;span class="c"&gt;# Reload the shell configuration&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt; — the actual value can end up in &lt;code&gt;.zsh_history&lt;/code&gt;, and Claude may be able to read it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ghp_abc123xyz…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Now&lt;/strong&gt; — the value itself never appears directly in the configuration.&lt;/p&gt;

&lt;p&gt;Claude only uses &lt;code&gt;$GITHUB_TOKEN&lt;/code&gt; from the environment.&lt;/p&gt;

&lt;p&gt;The actual secret stays in 1Password, and you never need to paste it into the chat.&lt;/p&gt;

&lt;p&gt;The MCP configuration can then inherit the variable from the environment instead of hardcoding it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"github"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"-i"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"--rm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"-e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"GITHUB_PERSONAL_ACCESS_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"ghcr.io/github/github-mcp-server"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, verify that Claude can still operate correctly.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;list my GitHub repositories
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the MCP server can still return your repositories, the flow works end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3.1: Rotate credentials
&lt;/h2&gt;

&lt;p&gt;If a key has been exposed, even briefly, rotate it.&lt;/p&gt;

&lt;p&gt;With AWS and GitHub this is usually quick, and rotating credentials reduces the amount of time an exposed key remains useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this does not solve
&lt;/h3&gt;

&lt;p&gt;If you paste a token directly into the chat, it is still sent to Anthropic.&lt;/p&gt;

&lt;p&gt;1Password protects you from accidental exposure through configuration files or environment setup.&lt;/p&gt;

&lt;p&gt;It does not protect you from secrets that you deliberately paste into a conversation.&lt;/p&gt;

&lt;p&gt;The most important security control is still your own workflow and habits.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Beyond credentials: securing Claude Code itself
&lt;/h2&gt;

&lt;p&gt;Managing secrets correctly is only part of the problem.&lt;/p&gt;

&lt;p&gt;Claude Code also has a permissions and configuration system that is worth hardening.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.1 Limit MCP servers
&lt;/h3&gt;

&lt;p&gt;Do not enable every MCP server by default.&lt;/p&gt;

&lt;p&gt;Only enable the ones you actually need and explicitly disable the others.&lt;/p&gt;

&lt;p&gt;This can be configured in &lt;code&gt;managed-settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"enabledMcpjsonServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"github"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"disabledMcpjsonServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"filesystem"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  5.2 Configure permissions carefully
&lt;/h4&gt;

&lt;p&gt;The remaining examples belong in &lt;code&gt;settings.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Claude Code provides three permission levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allow&lt;/strong&gt; — only for operations you consider completely harmless&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask&lt;/strong&gt; — operations with potential impact, such as &lt;code&gt;git push&lt;/code&gt; or &lt;code&gt;docker run&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deny&lt;/strong&gt; — operations that should always be blocked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important principle is the same: minimum privilege.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(curl:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(docker:*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(curl:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(docker:*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  5.3 Restrict access to sensitive directories
&lt;/h4&gt;

&lt;p&gt;You can also explicitly deny access to paths and files that may contain sensitive information:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(curl:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(rm -rf:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(~/.ssh/*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(~/.aws/*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(**/*.pem)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(**/.env*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  5.4 Automatic cleanup
&lt;/h4&gt;

&lt;p&gt;You can also configure cleanup of historical information:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cleanupPeriodDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Lessons learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Treat Claude Code like a junior developer with root access
&lt;/h3&gt;

&lt;p&gt;Claude Code can dramatically improve productivity.&lt;/p&gt;

&lt;p&gt;But if it is not configured properly, it can also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;expose credentials;&lt;/li&gt;
&lt;li&gt;corrupt repositories;&lt;/li&gt;
&lt;li&gt;open the door to unauthorized access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI coding tools are extremely useful.&lt;/p&gt;

&lt;p&gt;They should simply be treated with the same security discipline as any other tool that has access to your development environment.&lt;/p&gt;

&lt;p&gt;Have you run into something similar? Do you use another approach for managing credentials securely? Let me know in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.backslash.security/blog/claude-code-security-best-practices" rel="noopener noreferrer"&gt;https://www.backslash.security/blog/claude-code-security-best-practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rubenortiz.es/" rel="noopener noreferrer"&gt;https://www.rubenortiz.es/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.claude.com/en/articles/9767949-api-key-best-practices-keeping-your-keys-safe-and-secure" rel="noopener noreferrer"&gt;https://support.claude.com/en/articles/9767949-api-key-best-practices-keeping-your-keys-safe-and-secure&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>How I Reduced WordPress LCP from 7.7s to 2.5s</title>
      <dc:creator>rubendob</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:55:35 +0000</pubDate>
      <link>https://dev.to/rubendob/how-i-reduced-wordpress-lcp-from-77s-to-25s-58lf</link>
      <guid>https://dev.to/rubendob/how-i-reduced-wordpress-lcp-from-77s-to-25s-58lf</guid>
      <description>&lt;p&gt;A website can feel fast when you open it from a desktop computer with a good connection, but the experience can be very different on a mid-range mobile device using 4G.&lt;/p&gt;

&lt;p&gt;That was exactly what happened with my WordPress site.&lt;/p&gt;

&lt;p&gt;During a performance analysis, I found a particularly bad metric:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LCP: 7.7s
TBT: 64ms
CLS: 0.01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;Largest Contentful Paint was 7.7 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My target was clear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;7.7s → ≤ 2.5s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rather than installing another WordPress performance plugin and hoping for the best, I decided to investigate the complete request path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
   |
   v
CloudFront
   |
   v
Nginx
   |
   v
WordPress
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This led to several interesting findings involving images, CloudFront compression, Nginx configuration, JavaScript, caching and fonts.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Optimizing the background image
&lt;/h2&gt;

&lt;p&gt;One of the first large resources I found was the background image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fondo.jpg
394675 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was a 1920x1080 JPG weighing around &lt;strong&gt;395 KB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The first optimization was straightforward: convert it to WebP.&lt;/p&gt;

&lt;p&gt;The result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE
fondo.jpg
~395 KB

AFTER
fondo.webp
~187 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost half the amount of data for essentially the same visual result.&lt;/p&gt;

&lt;p&gt;Safari confirmed the new asset was being served correctly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content-Type: image/webp
Content-Length: 187212
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This alone wasn't going to explain a 7.7-second LCP, but it was an obvious improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Discovering that CloudFront wasn't compressing the main page
&lt;/h2&gt;

&lt;p&gt;The next thing I wanted to understand was how much HTML was actually being transferred.&lt;/p&gt;

&lt;p&gt;I tested it with &lt;code&gt;curl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Accept-Encoding: br, gzip'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-D&lt;/span&gt; /tmp/before-headers.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'download=%{size_download} bytes\nttfb=%{time_starttransfer}s\ntotal=%{time_total}s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://www.example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was approximately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;download=108015 bytes
ttfb=0.399917 s
total=0.518430 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around &lt;strong&gt;108 KB of HTML&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;More importantly, the response didn't contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content-Encoding: gzip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content-Encoding: br
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTML was being transferred without compression.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking CloudFront
&lt;/h3&gt;

&lt;p&gt;My specific CloudFront cache behaviors already had compression enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;ordered_cache_behavior&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;path_pattern&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/wp-content/uploads/*"&lt;/span&gt;

  &lt;span class="p"&gt;...&lt;/span&gt;

  &lt;span class="nx"&gt;compress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the main behavior didn't.&lt;/p&gt;

&lt;p&gt;I added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;default_cache_behavior&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;

  &lt;span class="nx"&gt;compress&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;viewer_protocol_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"redirect-to-https"&lt;/span&gt;

  &lt;span class="nx"&gt;min_ttl&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="nx"&gt;default_ttl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;
  &lt;span class="nx"&gt;max_ttl&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After applying the Terraform change, I tested an Autoptimize CSS file.&lt;/p&gt;

&lt;p&gt;Before compression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~272 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After compression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content-type: text/css
content-encoding: gzip
x-cache: Miss from cloudfront

download=45685 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CSS
~272 KB → ~46 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's roughly an &lt;strong&gt;83% reduction&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;CloudFront compression was working.&lt;/p&gt;

&lt;p&gt;But there was still a problem.&lt;/p&gt;

&lt;p&gt;The HTML wasn't being compressed.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The problem was also in Nginx
&lt;/h2&gt;

&lt;p&gt;To isolate CloudFront, I tested the Elastic Beanstalk origin directly.&lt;/p&gt;

&lt;p&gt;The response contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But still no:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content-Encoding: gzip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That meant the origin itself wasn't compressing dynamic WordPress HTML.&lt;/p&gt;

&lt;p&gt;I added the usual Nginx gzip configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_vary&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_proxied&lt;/span&gt; &lt;span class="s"&gt;any&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_comp_level&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_min_length&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;gzip_types&lt;/span&gt;
    &lt;span class="nc"&gt;text/plain&lt;/span&gt;
    &lt;span class="nc"&gt;text/css&lt;/span&gt;
    &lt;span class="nc"&gt;application/json&lt;/span&gt;
    &lt;span class="nc"&gt;application/javascript&lt;/span&gt;
    &lt;span class="nc"&gt;application/x-javascript&lt;/span&gt;
    &lt;span class="nc"&gt;text/xml&lt;/span&gt;
    &lt;span class="nc"&gt;application/xml&lt;/span&gt;
    &lt;span class="nc"&gt;application/xml&lt;/span&gt;&lt;span class="s"&gt;+rss&lt;/span&gt;
    &lt;span class="nc"&gt;text/javascript&lt;/span&gt;
    &lt;span class="nc"&gt;image/svg&lt;/span&gt;&lt;span class="s"&gt;+xml&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But surprisingly, HTML was still not compressed.&lt;/p&gt;

&lt;p&gt;That's when things got interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Gzip was enabled... only for HTTP
&lt;/h2&gt;

&lt;p&gt;I inspected the effective Nginx configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-T&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'listen .*443|listen .*80|server_name'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I found separate server blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;listen 80 default_server;

listen 443 ssl;
server_name www.example.com;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I checked where my gzip configuration was located.&lt;/p&gt;

&lt;p&gt;It was inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt; &lt;span class="s"&gt;default_server&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;...&lt;/span&gt;

    &lt;span class="s"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;gzip_vary&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;gzip_proxied&lt;/span&gt; &lt;span class="s"&gt;any&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;...&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the problem.&lt;/p&gt;

&lt;p&gt;My gzip configuration belonged only to the HTTP &lt;code&gt;server&lt;/code&gt; block listening on port 80.&lt;/p&gt;

&lt;p&gt;The actual application traffic was arriving through a different HTTPS server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;www.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;...&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTPS server wasn't inheriting the gzip configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;I moved the gzip configuration to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.platform/nginx/conf.d/gzip.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This placed it inside Nginx's global &lt;code&gt;http {}&lt;/code&gt; context.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http
 |
 +-- gzip on
 |
 +-- server :80
 |
 +-- server :443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both HTTP and HTTPS could now inherit the configuration.&lt;/p&gt;

&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Verifying gzip at the origin
&lt;/h2&gt;

&lt;p&gt;I tested the origin again.&lt;/p&gt;

&lt;p&gt;This time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Vary: Accept-Encoding
Content-Encoding: gzip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the transferred HTML size changed dramatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE
~108 KB

AFTER
~22 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Approximately an &lt;strong&gt;80% reduction&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This was one of the most useful discoveries during the whole investigation.&lt;/p&gt;

&lt;p&gt;Having:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;somewhere in your Nginx configuration does &lt;strong&gt;not&lt;/strong&gt; necessarily mean gzip is enabled for the traffic your users are actually receiving.&lt;/p&gt;

&lt;p&gt;Context matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Testing the full CloudFront path
&lt;/h2&gt;

&lt;p&gt;Now I could test the complete chain again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
   |
   | Accept-Encoding: gzip
   v
CloudFront
   |
   v
Nginx
   |
   | gzip
   v
WordPress
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first request returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content-encoding: gzip
vary: Accept-Encoding
x-cache: Miss from cloudfront

download=21769 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a second request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content-encoding: gzip
vary: Accept-Encoding
x-cache: Hit from cloudfront
age: 8

download=21769 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full path was finally behaving as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Deferring jQuery
&lt;/h2&gt;

&lt;p&gt;Lighthouse also identified &lt;code&gt;jquery.min.js&lt;/code&gt; as a render-blocking resource.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't its approximately 31 KB size.&lt;/p&gt;

&lt;p&gt;The problem was that the browser needed to download and process it before continuing the initial render.&lt;/p&gt;

&lt;p&gt;I was already using Autoptimize with its option to defer individual JavaScript files, but jQuery was explicitly excluded.&lt;/p&gt;

&lt;p&gt;After removing only that exclusion and clearing the Autoptimize cache, both &lt;code&gt;jquery-core&lt;/code&gt; and &lt;code&gt;jquery-migrate&lt;/code&gt; started loading with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;defer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;without breaking the site.&lt;/p&gt;

&lt;p&gt;Lighthouse then stopped listing jQuery under &lt;strong&gt;Render-blocking requests&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The measurements moved approximately from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FCP         1.7s → 1.5s
LCP         3.6s → 3.4s
Speed Index 3.5s → 3.3s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The improvement was relatively small and could partially fall within normal Lighthouse run-to-run variation.&lt;/p&gt;

&lt;p&gt;The important result was that JavaScript had been removed from the critical rendering path without breaking dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Caching hashed assets for one year
&lt;/h2&gt;

&lt;p&gt;I also found that Autoptimize-generated files were cached for only 30 days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cache-Control: max-age=2592000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical generated file looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;autoptimize_b7e1ec6b8133370482e43a1431d3a757.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These assets contain a hash in their filename.&lt;/p&gt;

&lt;p&gt;When the content changes, their URL changes as well.&lt;/p&gt;

&lt;p&gt;That means they are excellent candidates for long-lived browser caching.&lt;/p&gt;

&lt;p&gt;I added a specific Nginx rule for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/wp-content/cache/autoptimize/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;1y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response then became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cache-Control: max-age=31536000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This improves repeat visits by avoiding unnecessary downloads of immutable assets.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Changing &lt;code&gt;font-display: block&lt;/code&gt; to &lt;code&gt;swap&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;One of the final Lighthouse findings involved the Flatsome icon font.&lt;/p&gt;

&lt;p&gt;It was using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;font-display&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;block&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;block&lt;/code&gt;, the browser may wait for the font before displaying content that depends on it.&lt;/p&gt;

&lt;p&gt;I changed the behavior to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;font-display&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;swap&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;using a customization in the WordPress child theme instead of modifying the parent theme directly.&lt;/p&gt;

&lt;p&gt;After deploying the change, my measurements went from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LCP: 3.4s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LCP: 2.5s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FCP remained around:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.5s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TBT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and CLS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.007
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single Lighthouse execution doesn't prove that the entire 0.9-second improvement was caused by this change alone, but the result was encouraging.&lt;/p&gt;

&lt;p&gt;Most importantly, the LCP finally reached my original target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final results
&lt;/h2&gt;

&lt;p&gt;Some of the biggest improvements were easy to quantify:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Background image&lt;/td&gt;
&lt;td&gt;~395 KB JPG&lt;/td&gt;
&lt;td&gt;~187 KB WebP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Home HTML&lt;/td&gt;
&lt;td&gt;~108 KB&lt;/td&gt;
&lt;td&gt;~22 KB gzip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autoptimize CSS&lt;/td&gt;
&lt;td&gt;~272 KB&lt;/td&gt;
&lt;td&gt;~46 KB gzip&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;108015 bytes
     ↓
~21788 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around &lt;strong&gt;80% less transferred data&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;271802 bytes
     ↓
45685 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around an &lt;strong&gt;83% reduction&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And, most importantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initial LCP
7.7s

      ↓

Final Lighthouse LCP
2.5s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A note about TTFB
&lt;/h2&gt;

&lt;p&gt;During testing I saw one request with a TTFB above six seconds.&lt;/p&gt;

&lt;p&gt;It would have been easy to conclude that the origin server had a serious performance problem.&lt;/p&gt;

&lt;p&gt;Instead, I repeated several forced cache-miss requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MISS 1  TTFB=1.648s
MISS 2  TTFB=0.831s
MISS 3  TTFB=0.858s
MISS 4  TTFB=0.674s
MISS 5  TTFB=0.739s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The six-second request was clearly an outlier rather than normal server behavior.&lt;/p&gt;

&lt;p&gt;This is a useful reminder when doing performance work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;don't optimize based on a single measurement.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Repeat your tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;My initial goal was simply to improve a very poor LCP score.&lt;/p&gt;

&lt;p&gt;But the most interesting part of the investigation was that there wasn't one magical fix.&lt;/p&gt;

&lt;p&gt;The final improvement came from several layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Heavy JPG
    ↓
WebP

Uncompressed responses
    ↓
gzip

gzip in the wrong Nginx context
    ↓
global http{} configuration

Render-blocking jQuery
    ↓
defer

Short-lived hashed assets
    ↓
1-year cache

font-display: block
    ↓
font-display: swap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail was particularly easy to miss:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Enabling gzip in Nginx doesn't necessarily mean every virtual host is using it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my case, gzip was configured inside the port 80 &lt;code&gt;server&lt;/code&gt; block while the real traffic was being served through the independent HTTPS server on port 443.&lt;/p&gt;

&lt;p&gt;Moving gzip to the global configuration fixed the issue.&lt;/p&gt;

&lt;p&gt;Performance work often ends up being less about installing another optimization plugin and more about understanding &lt;strong&gt;where the browser is actually spending its time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The improvements were also reflected in external performance tools. In Wakaris, the site's performance score increased from 33% to 83%, while accessibility improved from 78% to 94%, SEO positioning from 78% to 89%, presence from 33% to 100%, and security from 63% to 82%. &lt;/p&gt;

&lt;p&gt;The changes were also clearly visible in Google PageSpeed Insights, where the desktop version reached a 99/100 performance score, while the mobile version also showed a well-optimized result. These measurements confirmed that the work was not only reducing individual resource sizes, but improving the overall performance and quality of the site.&lt;/p&gt;




&lt;p&gt;Originally published in Spanish on &lt;strong&gt;RubenOrtiz.es&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.rubenortiz.es/2026/10/06/optimizar-wordpress-lcp/" rel="noopener noreferrer"&gt;Read the original Spanish article&lt;/a&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webperf</category>
      <category>aws</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
