TLS and certificates
The ALB needs two separate pieces of certificate material:
- A server certificate stored in ACM so the ALB can terminate HTTPS on
ports
443and8443. For tests this is usually a Let's Encrypt certificate. - An FNMT trust bundle stored in S3 and referenced by an ALB trust store.
This bundle contains only FNMT certification authority certificates and is
used by the
8443listener inverifymode to validate citizen client certificates.
These are independent: the server certificate proves the identity of the service, the trust bundle validates the identity of the citizen.
1. Create a test Let's Encrypt certificate
The script infrastructure/resources/scripts/generate-letsencrypt-cert.sh
outlines the fastest way to obtain a certificate for the sample domain
model-city.example.org using a DNS-01 challenge. The process is:
- Install
certbot(oracme.sh). - Request a certificate for your public domain with DNS validation:
sudo certbot certonly --manual --preferred-challenges dns -d model-city.example.orgnote
Let's Encrypt retired the classic
ISRG Root X1-anchored alternate chain on 2026-08-18 (the "Generation Y" rollout) —--preferred-chain "ISRG Root X1"no longer matches anything, so don't bother passing it. The default chain (... → Root YE → ISRG Root X2) has validated fine in testing, including from inside a plainnode:20-slimcontainer calling the ALB over the public domain. If a future renewal ever serves a chain that a client inside your Docker images can't validate (unable to verify the first certificate, silently swallowed by the API clients if it's the frontend calling the backend), that's a CA-side trust-store propagation issue, not something fixed by a--preferred-chainflag — check the Let's Encrypt community forum for the current state of Generation Y root trust before chasing it further. - Add the
_acme-challenge.model-city.example.orgTXT record your DNS provider asks for, wait for propagation, then press Enter. - The certificate and private key are written to
/etc/letsencrypt/live/model-city.example.org/. - Copy them to the working directory used by the import script:
mkdir -p letsencrypt-certssudo cp /etc/letsencrypt/live/model-city.example.org/fullchain.pem letsencrypt-certs/sudo cp /etc/letsencrypt/live/model-city.example.org/privkey.pem letsencrypt-certs/sudo chmod 644 letsencrypt-certs/*.pem
Alternatively, automate the DNS challenge with acme.sh if your DNS provider
has a plugin:
curl https://get.acme.sh | sh
~/.acme.sh/acme.sh --issue --dns dns_<provider> -d model-city.example.org --server letsencrypt
Replace model-city.example.org with your real domain before running the
scripts. The same domain must be set in terraform.tfvars as domain.
2. Import the certificate into ACM
Run infrastructure/resources/scripts/import-cert-to-acm.sh from the same
resources/scripts/ directory. It expects the files in letsencrypt-certs/:
privkey.pem— the private key.- Either
cert.pem+chain.pem, or afullchain.pemthat the script will split into the two parts.
The script:
- Verifies the private key exists.
- Extracts the server certificate and the intermediate chain from
fullchain.pemifcert.pem/chain.pemare not present. - Imports the certificate into ACM in
us-east-1:aws acm import-certificate \--certificate fileb://cert.pem \--certificate-chain fileb://chain.pem \--private-key fileb://privkey.pem \--region us-east-1 - Prints the resulting
CertificateArn. - Reminds you to copy the ARN into
terraform.tfvarsasacm_certificate_arnand to point your DNS to the ALB.
cd infrastructure/resources/scripts
./generate-letsencrypt-cert.sh
# follow the manual DNS steps, then copy the cert files
./import-cert-to-acm.sh
# copy the printed ARN into terraform.tfvars
The import script uses us-east-1 because that is the default region for the
Model City stacks. The ALB and the ACM certificate must be in the same region.
3. Build the FNMT trust bundle
The ALB listener on port 8443 validates citizen client certificates in
verify mode. The list of accepted CAs is supplied as an ALB trust store
that points to a PEM bundle in S3.
The repository already contains the FNMT chain files:
infrastructure/resources/ac_raiz_fnmt_g2.pem— root CA, AC Raíz FNMT-RCM G2.infrastructure/resources/ac_usuarios_g2.pem— intermediate CA, AC Usuarios G2, that issues citizen personal certificates.infrastructure/resources/fnmt-client-trust-bundle.pem— concatenation of the two previous files, ready to upload.
A typical citizen certificate chain looks like:
AC Raíz FNMT-RCM G2
└── AC Usuarios G2
└── Citizen's FNMT personal certificate
If you need to rebuild the bundle (for example because the citizen certificate you are testing with was issued by a different FNMT intermediate such as AC Representación G2 or AC Sector Público G2), concatenate the PEM files in the correct order:
cat ac_raiz_fnmt_g2.pem ac_usuarios_g2.pem > fnmt-client-trust-bundle.pem
The trust bundle must contain only public CA certificates. Never include a citizen's personal certificate or its private key.
4. Wire the trust bundle into Terraform
Set the local path in terraform.tfvars:
mtls_trust_bundle_path = "../../../resources/fnmt-client-trust-bundle.pem"
alb.tf then:
- Creates an S3 bucket (
${local.name}-mtls-truststore-...). - Uploads the bundle as
fnmt-client-trust-bundle.pem. - Creates an
aws_lb_trust_storeresource named${local.name}-fnmtthat points at the S3 object. - Attaches the trust store to the
8443listener withmutual_authenticationset tomode = "verify".
resource "aws_lb_listener" "https_mtls" {
load_balancer_arn = aws_lb.public.arn
port = 8443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.acm_certificate_arn
mutual_authentication {
mode = "verify"
trust_store_arn = aws_lb_trust_store.fnmt.arn
}
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.gateway.arn # or monolith
}
}
5. Certificate renewal
Let's Encrypt certificates are valid for 90 days. Before they expire, renew and re-import:
sudo certbot renew
./import-cert-to-acm.sh
Then update acm_certificate_arn in terraform.tfvars with the new ARN and run
terraform apply so the ALB picks it up. For production workloads, consider a
managed ACM certificate or an automated renewal pipeline instead of the manual
script.
Then update acm_certificate_arn in terraform.tfvars with the new ARN and run
terraform apply so the ALB picks it up. For production workloads, consider a
managed ACM certificate or an automated renewal pipeline instead of the manual
script.