The previous lesson ended by pointing at two debts you have been carrying since Module 5, and both are cryptographic. The first: db_password is written in the clear inside /etc/tramontana/app.conf, and you have already seen that any permission failure turns that into a credential leak no amount of watching can prevent. The second: the booking traffic — with the guests' names you have taken such care to protect inside the backups — travels unencrypted over port 8080. This lesson resolves both, because they are the same problem seen from two angles: keeping a secret and proving an identity.
They are two clearly separate halves. In the first you will learn how to take a secret out of a configuration file and manage it with the tools the system already gives you: strict permissions, systemd's EnvironmentFile and LoadCredential, GPG, pass and LUKS. In the second, what a certificate really is, how it is generated, read and verified, how to obtain a free and automatically renewed one from Let's Encrypt, and how to watch its expiry — which is the silliest and most frequent production failure of all.
A note on scope. Here you obtain, install and verify the cryptographic material for bookings.tramontana.example. The web server that will serve it — Nginx as a reverse proxy in front of the application on port 8080 — is built in project 08-01. That is deliberate: encryption in transit is prepared and tested now so that in Module 8 it is a configuration detail and not a new topic.
Compliance warning. The encryption of personal data, in transit and at rest, is not an optional improvement: the GDPR requires it as an appropriate technical measure (art. 32). bookings.csv contains guests' names. In a real environment, the design of key management and key custody must be reviewed by the security officer, and decisions about personal data by the data protection officer.
Contents
- What a secret is and where it never goes
- The decent minimum: permissions, EnvironmentFile and LoadCredential
- Encrypting secrets at rest: GPG and pass
- Disk encryption with LUKS
- Centralised secret managers
- Credential rotation
- What TLS guarantees and what a certificate contains
- Generating, reading and verifying cryptographic material with OpenSSL
- Let's Encrypt and automatic renewal
- TLS good practice, key custody and expiry monitoring
What a secret is and where it never goes
A secret is a piece of data whose value depends on only the right people knowing it: a password, an API key, a token, a private key, the passphrase of a backup repository. It differs from any other piece of sensitive data in that its compromise is not a privacy problem but a control problem: whoever has it can act in your name.
The first rule is a negative one, and it is worth understanding the why of each entry because each corresponds to a real and frequent leak:
| Where a secret does NOT go | Why |
|---|---|
| In the source code | It ends up in the repository, and in git the history is permanent: deleting the line does not delete the commit |
| On the command line | Any user on the system sees it with ps aux while the process is running |
| In a process's environment variable | Readable in /proc/<pid>/environ by its owner and by root; and it is inherited by child processes |
| In the logs | A set -x or a log "connecting with $PASS" writes it to the journal, which is kept for 30 days |
| In an unencrypted backup | It is the incident you already lived through: the backup with 644 permissions |
| In a readable configuration file | It is the current state of app.conf, and it is what this lesson fixes |
The first two deserve a demonstration, because people underestimate them:
# A secret on the command line is public while the process lives
$ ps aux | grep -m1 'psql'
luis 3417 0.0 0.1 ... psql -h 10.0.2.15 -U tramontana --password=Zx9K2pQ
# And a process's environment is readable by its owner
$ sudo tr '\0' '\n' < /proc/1284/environ | grep -i pass
TRAMONTANA_DB_PASSWORD=Zx9K2pQIt is worth being precise about the second case, because there is a nuance that is often misread: /proc/<pid>/environ has 0400 permissions and only the process's owner and root can read it. That makes environment variables much better than the command line, but it does not make them a good place: they are inherited by every child process, they show up in memory dumps and in the error traces of many frameworks, and any escalation to root exposes them all at once.
The decent minimum: permissions, EnvironmentFile and LoadCredential
You do not need infrastructure to improve substantially. With what you already know about permissions (02-07), service users (05-01) and systemd units (05-05), there are three levels, each better than the last.
Level 1: a secrets file with strict permissions
The idea is to separate the secret from the configuration: app.conf stops containing the password and becomes a file readable by the group, while the secret lives in a separate file with minimal permissions.
# The secrets file: owned by the service user, only it can read it
$ sudo install -o svc-tramontana -g svc-tramontana -m 600 /dev/null \
/etc/tramontana/secrets.env
$ sudo tee /etc/tramontana/secrets.env >/dev/null <<'EOF'
TRAMONTANA_DB_PASSWORD=Zx9K2pQ7vLm4RtWn
EOF
$ sudo ls -l /etc/tramontana/
total 8
-rw-r----- 1 root tramontana 312 Aug 18 12:04 app.conf
-rw------- 1 svc-tramontana svc-tramontana 42 Aug 18 12:06 secrets.envAnd the unit loads it into the service's environment, without it ever appearing on the command line:
# /etc/systemd/system/tramontana.service.d/override.conf
[Service]
EnvironmentFile=/etc/tramontana/secrets.envRemember the course's convention: app.conf is chattr +i, so editing it means removing the attribute, making the change and putting it back — with its prior copy:
$ sudo cp -p /etc/tramontana/app.conf /etc/tramontana/app.conf.bak-$(date +%F)
$ sudo chattr -i /etc/tramontana/app.conf
$ sudo sed -i.bak-$(date +%F) '/^db_password/d' /etc/tramontana/app.conf
$ sudo chattr +i /etc/tramontana/app.conf
$ sudo diff -u /etc/tramontana/app.conf.bak-$(date +%F) /etc/tramontana/app.conf
--- /etc/tramontana/app.conf.bak-2026-08-18
+++ /etc/tramontana/app.conf
@@ -3,7 +3,6 @@
db_name=tramontana_bookings
-db_password=Zx9K2pQ7vLm4RtWn
max_connections=200This already closes the exposure from the last exercise of 06-04: an accidental chmod 644 on app.conf no longer leaks anything. But the secret is still in the clear on disk and in the process's environment.
Level 2: LoadCredential
Ubuntu 24.04 ships systemd's credential mechanism, which is clearly superior: the secret is delivered to the service in a file inside a private tmpfs accessible only to that service, it is not inherited by children and it does not appear in the environment.
# /etc/systemd/system/tramontana.service.d/override.conf
[Service]
LoadCredential=db_password:/etc/tramontana/secrets/db_password$ sudo install -d -o root -g root -m 700 /etc/tramontana/secrets
$ printf '%s' 'Zx9K2pQ7vLm4RtWn' | sudo tee /etc/tramontana/secrets/db_password >/dev/null
$ sudo chmod 600 /etc/tramontana/secrets/db_passwordThe service receives the path in the CREDENTIALS_DIRECTORY variable and reads the file:
# Inside the service, the secret is at:
# ${CREDENTIALS_DIRECTORY}/db_password
$ systemd-run --property=LoadCredential=test:/etc/tramontana/secrets/db_password \
--pty bash -c 'ls -l "$CREDENTIALS_DIRECTORY"; cat "$CREDENTIALS_DIRECTORY/test"'
total 0
-r--r----- 1 root root 16 Aug 18 12:22 test
Zx9K2pQ7vLm4RtWnThe difference from EnvironmentFile in a table, because it decides the choice:
EnvironmentFile= |
LoadCredential= |
|
|---|---|---|
Visible in /proc/<pid>/environ |
Yes | No |
| Inherited by child processes | Yes | No |
Appears in systemctl show |
Yes (the file's name) | Only the source path |
Works with ProtectSystem=strict |
Yes | Yes, and the tmpfs is private |
| Compatible with any application | Yes, nearly all read from the environment | Requires the app to read from a file |
Level 3: systemd-creds, an encrypted secret on disk
The step that closes the circle: systemd-creds encrypts the secret with a key derived from the system (and from the TPM if there is one), so that the file on disk no longer contains the secret in the clear.
$ printf '%s' 'Zx9K2pQ7vLm4RtWn' \
| sudo systemd-creds encrypt --name=db_password - /etc/tramontana/secrets/db_password.cred
$ sudo cat /etc/tramontana/secrets/db_password.cred | head -c 80
-----BEGIN CREDENTIAL-----
CqiVzUCu5c1TF4TBTdxr4hK+3XPqmn9lBTJmMWk0YjMwNzY2MzQ0ZTk...# /etc/systemd/system/tramontana.service.d/override.conf
[Service]
LoadCredentialEncrypted=db_password:/etc/tramontana/secrets/db_password.cred$ sudo systemctl daemon-reload && sudo systemctl restart tramontana.service
$ sudo systemctl status tramontana.service --no-pager | head -5
● tramontana.service - Tramontana Bookings
Loaded: loaded (/etc/systemd/system/tramontana.service; enabled)
Active: active (running) since Tue 2026-08-18 12:31:08 CEST; 4s ago
Main PID: 4102 (tramontana)
# Verification: the secret is no longer in the process's environment
$ sudo tr '\0' '\n' < /proc/4102/environ | grep -ci pass
0That 0 is the result you were after. The third open incident is now closed: the password is no longer in the clear in any configuration file, it is not in the process's environment, it is not readable by any user on the system and — an important consequence — nor does it travel in the clear inside the restic backup, because what gets copied is the encrypted file.
One honest limitation: systemd-creds's encryption key is tied to the machine (/var/lib/systemd/credential.secret). That is exactly what you want against the leak of a file, but it means the .cred file cannot be restored on a rebuilt server. The original secret has to be somewhere you can recover it from as well: that is the next section, and it is what has to be recorded in the runbook from 05-08.
Encrypting secrets at rest: GPG and pass
You need a place where the original secret lives encrypted, recoverable by an authorised person, with a change history and off the server. The classic answer on Linux is GPG, and pass built on top of it.
GPG in two modes
# Symmetric: one passphrase, no keys. Simple and enough for a one-off file
$ gpg -c --cipher-algo AES256 runbook-credentials.txt
$ ls runbook-credentials.txt.gpg
$ gpg -d runbook-credentials.txt.gpg > /dev/shm/recovered.txt
# Asymmetric: encrypted for specific recipients, with no shared passphrase
$ gpg --quick-generate-key "operator (Tramontana) <[email protected]>" ed25519 cert 2y
$ gpg -e -r [email protected] -r [email protected] secrets.txt
$ gpg -d secrets.txt.gpgThe practical difference is one of management: the symmetric mode forces you to transmit the passphrase through another channel and to change it when somebody leaves the team; the asymmetric one lets you encrypt for several people and revoke one person's access without re-encrypting for the rest. In a team, always asymmetric.
Notice the > /dev/shm/recovered.txt in the decryption: /dev/shm is memory, not disk. Writing a decrypted secret to the filesystem leaves recoverable traces even after you delete it.
pass: the manager built on GPG
pass keeps each secret in a GPG-encrypted file inside a git repository. It is simple, auditable and depends on no service.
$ sudo apt install pass
$ pass init [email protected]
mkdir: created directory '/home/operator/.password-store/'
Password store initialized for [email protected]
$ pass git init
$ pass insert tramontana/production/db_password
Enter password for tramontana/production/db_password:
Retype password for tramontana/production/db_password:
[master 8f2c1a4] Add given password for tramontana/production/db_password to store.
$ pass insert -m tramontana/production/restic
# (-m allows several lines: password, repository, notes)
$ pass ls
Password Store
└── tramontana
└── production
├── db_password
└── restic
$ pass tramontana/production/db_password
Zx9K2pQ7vLm4RtWn
$ pass -c tramontana/production/db_password
Copied tramontana/production/db_password to clipboard. Will clear in 45 seconds.Three details that make it fit for production:
-
A full history with git.
pass git log --onelineshows every addition, change and removal, with a date and an author. It is the traceability of rotation. -
The files are encrypted, so the repository can be synchronised to a remote without exposing anything:
pass git remote add origin ...andpass git push. That solves the "off the server" requirement. -
Composition with scripts.
passwrites to stdout, so it fits with everything from Module 4:# Generate the .cred file from pass, without the secret ever touching the disk in the clear $ pass tramontana/production/db_password \\ | sudo systemd-creds encrypt --name=db_password - \\ /etc/tramontana/secrets/db_password.credThat pipeline is the answer to the recoverability problem from the previous section:
passis the source of truth,systemd-credsis the operational copy tied to the machine, and rebuilding the server consists of running this line again.
And the obvious warning: the private GPG key and its passphrase are now the master key. Its backup (gpg --export-secret-keys, encrypted, on separate physical media) and its custody are part of the runbook, and losing it amounts to losing every secret.
Disk encryption with LUKS
The secrets are encrypted now, but /srv/tramontana/backups contains bookings.csv with guests' names. If somebody takes the disk away — or the VM image, or the disk of a decommissioned server that was never wiped — the filesystem's permissions protect nothing: it gets mounted on another machine and everything is readable.
LUKS is the block encryption standard on Linux. It encrypts the whole device, underneath the filesystem.
# On a NEW device, or one whose contents you can afford to lose: luksFormat DESTROYS the data
$ sudo cryptsetup luksFormat --type luks2 /dev/vg-data/lv-backups
WARNING!
========
This will overwrite data on /dev/vg-data/lv-backups irrevocably.
Are you sure? (Type 'yes' in capital letters): YES
Enter passphrase for /dev/vg-data/lv-backups:
$ sudo cryptsetup luksOpen /dev/vg-data/lv-backups backups-encrypted
$ sudo mkfs.ext4 -L backups /dev/mapper/backups-encrypted
$ sudo mount /dev/mapper/backups-encrypted /srv/tramontana/backups
$ sudo cryptsetup luksDump /dev/vg-data/lv-backups | head -8
LUKS header information
Version: 2
Epoch: 3
Metadata area: 16384 [bytes]
Keyslots:
0: luks2
Key: 512 bits
Cipher: aes-xts-plain64And here is the real problem, which has to be stated without decoration: a LUKS volume needs a passphrase in order to open, and a server has to boot on its own at three in the morning. The options, with their trade-offs:
| Option | How | Cost |
|---|---|---|
| A manual passphrase | Somebody types it at every boot | Maximum protection; the server does not boot without intervention |
| A key file on the root disk | /etc/crypttab with keyfile |
Automatic; it protects only against theft of the data disk, not the root one |
| TPM | systemd-cryptenrol --tpm2-device=auto |
Automatic and tied to the hardware; requires a TPM and careful configuration |
| Remote unlocking at boot | dropbear-initramfs |
Automatic with supervision; more moving parts to maintain |
For the lab, the key file option with nofail is reasonable, and it is important to understand exactly what it protects:
# /etc/crypttab
# name device (by UUID) key options
backups-encrypted UUID=8f4c2a19-7d3e-4b1a-9c85-2e6f0a4d7b31 /etc/luks/backups.key luks,nofail$ sudo install -d -m 700 /etc/luks
$ sudo dd if=/dev/urandom of=/etc/luks/backups.key bs=512 count=1
$ sudo chmod 400 /etc/luks/backups.key
$ sudo cryptsetup luksAddKey /dev/vg-data/lv-backups /etc/luks/backups.key# /etc/fstab (the backups line now points at the unlocked device)
/dev/mapper/backups-encrypted /srv/tramontana/backups ext4 defaults,noatime,nodev,nosuid,nofail 0 2# The compulsory safety net before rebooting, as in 05-04
$ sudo systemctl daemon-reload && sudo mount -a && findmnt /srv/tramontana/backupsWhat it protects and what it does not, stated precisely: it protects against the theft or removal of the data disk, against access to the image of the stopped VM, and against data recovery from a discarded disk. It does not protect against an attacker who compromises the running server, because as far as they are concerned the volume is mounted and readable just as it is for anybody else. It is encryption at rest, not encryption in use, and presenting it as anything else to Marta would be misleading her.
Notice too how it composes with what came before: restic already encrypts its repository with its own password — which now lives in pass — so the backups have two independent layers. And for the off-site destination of the 3-2-1 rule, restic's encryption is the one that really matters, because you do not control the remote medium.
Centralised secret managers
Everything above is correct for one machine. When there are several, a new problem appears: distributing and rotating secrets across N servers without copying them by hand.
| Solution | Model | When it pays off |
|---|---|---|
pass + git |
GPG files in a repository | 1-5 machines, a small team, no dependencies |
sops + age |
Encrypts only the values of a YAML/JSON, versionable in git | Configuration as code; it fits very well with Ansible (07-06) |
| HashiCorp Vault | A service with an API, policies, auditing and short-lived dynamic secrets | Dozens of machines, several teams, audit requirements |
| A cloud Secrets Manager (AWS/GCP/Azure) | A managed service integrated with IAM | Infrastructure already at that provider |
The point where it starts paying off has a fairly identifiable threshold: when rotating a credential stops being a five-minute task. With one server, rotating means changing pass, regenerating the .cred and restarting the service. With twenty, without a central manager, it is a manual operation prone to leaving machines half done — which is the worst of all worlds, because you have the cost of the rotation without the guarantee.
sops deserves a specific mention because it will be relevant in 07-06: it lets you keep an Ansible variables file in git where the keys are readable and the values are encrypted, so that the diff of a configuration change remains reviewable without exposing the secrets.
Vault's other advantage, which the rest do not have, is dynamic secrets: instead of a fixed database password, it generates credentials valid for one hour, specific to each process. A secret that expires on its own needs no rotation, and a leak has a minimal exploitation window. It is the model the industry is heading towards.
Credential rotation
Rotating means replacing a secret with a new one. It is compulsory because the probability that a secret has been compromised only grows with time: every copy, every log, every person who saw it, every machine it has been on. Rotating limits the exploitation window of a leak that may already have happened without your knowing.
When to rotate, in order of urgency:
- Immediately, on any suspicion of exposure. It is what you did with
db_passwordwhen it turned up in the backup with 644 permissions, and what outcome B of the last exercise in 06-04 would do. - When somebody with access leaves the team. It is not distrust: it is that control over who knows the secret has been lost.
- Periodically, with a written interval. For a service credential like this one, between 90 days and a year is usual, depending on the exposure.
The procedure without interrupting the service is the part with technical substance, and it depends on a property of the target system: that it accepts two valid credentials at once. PostgreSQL does not do that with the same account, so the pattern uses two users:
# 1. Create the new credential alongside the old one (both valid)
$ sudo -u postgres psql -c \
"CREATE ROLE tramontana_app2 LOGIN PASSWORD 'newly-generated-key';"
$ sudo -u postgres psql -c \
"GRANT ALL PRIVILEGES ON DATABASE tramontana_bookings TO tramontana_app2;"
# 2. Store it in the source of truth, with history
$ pass insert tramontana/production/db_password
$ pass git log --oneline -1
a3f1c88 Add given password for tramontana/production/db_password to store.
# 3. Update the operational copy and apply it
$ pass tramontana/production/db_password \
| sudo systemd-creds encrypt --name=db_password - \
/etc/tramontana/secrets/db_password.cred
$ sudo systemctl restart tramontana.service
# 4. VERIFY before withdrawing the old one
$ ~/scripts/health_check.sh; echo "status: $?"
status: 0
# 5. Only now, withdraw the old credential
$ sudo -u postgres psql -c "DROP ROLE tramontana_app;"The order is what matters: create, apply, verify, withdraw. Swapping the last two steps leaves the service down until somebody notices, and at three in the morning that means hours. It is the same "measure before and after" discipline you applied in 05-07.
A decent way to generate passwords, so as not to give in to the temptation of inventing them:
$ pass generate -n tramontana/production/db_password 32
$ openssl rand -base64 24 # alternative without pass
Kj8mQ2vX9pLnR4tW7cYbF3sZ6dHaWhat TLS guarantees and what a certificate contains
The second half of the lesson. TLS (Transport Layer Security, SSL's successor) gives three guarantees, and it is worth separating them because the third is the one almost everybody forgets:
| Guarantee | What it means | Without it |
|---|---|---|
| Confidentiality | Nobody on the path can read the content | Whoever is on the network sees the guests' data |
| Integrity | Nobody can modify the content undetected | A booking can be altered in transit |
| Authentication | You are talking to who you think you are | You encrypt beautifully... with the attacker |
The third is the reason certificates exist. Encryption without authentication is useless against a man in the middle: if an attacker interposes themselves, negotiates one encrypted connection with you and another with the real server, they read everything. It is the same reasoning as the SSH fingerprint warning in 06-02.
And what TLS does not guarantee: that the server is honest, that the application is secure, that the data is encrypted at rest, or that whoever connects is who they say they are (for that you need to authenticate the client).
The chain of trust
The problem to solve is one of bootstrapping: how to trust the public key of a server you have never seen before. The solution is to delegate to a third party you already trust.
graph TD
R["Root CA<br/>(in the system's<br/>and browser's store)"] -->|signs| I["Intermediate CA<br/>(e.g. Let's Encrypt E6)"]
I -->|signs| H["Leaf certificate<br/>bookings.tramontana.example"]
H -.->|contains| K["The server's public key"]
S["Private key<br/>(never leaves the server)"] -.->|pairs with| K
Verification consists of checking, upwards, that each certificate is signed by the next one, until you reach a root that is already in the local store (/etc/ssl/certs/, from the ca-certificates package). The root CA signs little and lives heavily protected; the intermediates do the day-to-day work and can be revoked without invalidating the root.
What is inside a certificate
A certificate is the server's public key plus a set of assertions, all signed by the CA:
| Field | Content | Note |
|---|---|---|
| Subject | Who it identifies | The CN is obsolete for host names |
| SAN (Subject Alternative Name) | The valid names | This is the field validated today |
| Issuer | Who signed it | Links to the chain |
| Not Before / Not After | The validity window | The cause of the most common failure |
| Public Key | Public key and algorithm | ECDSA P-256 or RSA 2048+ |
| Key Usage / Extended Key Usage | What it can be used for | serverAuth, clientAuth |
The point about the SAN is practical, not anecdotal: for years now browsers and TLS libraries have ignored the CN and validated exclusively against the SAN. A certificate with CN=bookings.tramontana.example and no SAN is rejected. It is a classic source of lost hours when generating certificates by hand.
The TLS 1.3 handshake, in five lines and without going into cryptography:
- The client sends the algorithms it supports and its contribution to the key exchange.
- The server chooses, sends its own contribution and its certificate.
- The client verifies the certificate against its CA store and checks that the name is in the SAN.
- Both derive the same session key without ever having transmitted it.
- From then on, everything travels encrypted with that key. A single round trip.
Generating, reading and verifying cryptographic material with OpenSSL
openssl is the Swiss army knife of this field. Four operations cover 90% of the work.
Generating a private key and a signing request
# ECDSA P-256 private key: shorter and faster than RSA, with security equivalent to RSA 3072
$ openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
-out bookings.key
$ chmod 600 bookings.key
# CSR with the SAN correctly set (the part people forget)
$ openssl req -new -key bookings.key -out bookings.csr \
-subj "/CN=bookings.tramontana.example/O=Tramontana S.L./C=ES" \
-addext "subjectAltName=DNS:bookings.tramontana.example,DNS:www.bookings.tramontana.example"
$ openssl req -in bookings.csr -noout -text | grep -A2 'Alternative'
X509v3 Subject Alternative Name:
DNS:bookings.tramontana.example, DNS:www.bookings.tramontana.exampleThe CSR (Certificate Signing Request) contains the public key and the requested names, and it is signed with the private key to prove that you hold it. The private key never leaves the server.
Self-signing for testing
$ openssl x509 -req -in bookings.csr -signkey bookings.key -days 365 \
-copy_extensions copyext -out bookings-selfsigned.crtThat -copy_extensions copyext is essential: without it, the CSR's SAN is not copied into the certificate and you get a useless one. It is the most frequent mistake when generating test certificates by hand.
A self-signed certificate is good for testing the server's configuration, and for that alone: no CA vouches for it, so every client will warn. Never in production.
Reading and verifying
$ openssl x509 -in /etc/letsencrypt/live/bookings.tramontana.example/cert.pem \
-noout -text | grep -A1 -E 'Issuer:|Not Before|Not After|Alternative'
Issuer: C = US, O = Let's Encrypt, CN = E6
Not Before: Aug 18 09:12:41 2026 GMT
Not After : Nov 16 09:12:40 2026 GMT
X509v3 Subject Alternative Name:
DNS:bookings.tramontana.example
# Verify the full chain against the system store
$ openssl verify -untrusted /etc/letsencrypt/live/bookings.tramontana.example/chain.pem \
/etc/letsencrypt/live/bookings.tramontana.example/cert.pem
/etc/letsencrypt/live/bookings.tramontana.example/cert.pem: OK
# Check that the private key matches the certificate (the moduli must be identical)
$ openssl pkey -in privkey.pem -pubout -outform DER | sha256sum
$ openssl x509 -in cert.pem -pubkey -noout -outform DER | sha256sumThat last check resolves a baffling and frequent failure: the server does not start or refuses connections because the key and the certificate are not a pair, typically after regenerating one and forgetting the other. If the two sums match, they are a pair.
And inspecting a running server:
$ openssl s_client -connect bookings.tramontana.example:443 \
-servername bookings.tramontana.example </dev/null 2>/dev/null \
| openssl x509 -noout -dates -subject
notBefore=Aug 18 09:12:41 2026 GMT
notAfter=Nov 16 09:12:40 2026 GMT
subject=CN = bookings.tramontana.exampleThe -servername is compulsory when there are several sites on the same IP: it is the SNI extension, which says which certificate you want. Without it you will receive the default certificate and believe there is a problem where there is none.
Let's Encrypt and automatic renewal
Let's Encrypt is a free, automated CA. Its protocol, ACME, works on a simple idea: to prove that you control a domain, the CA asks you to do something only its owner could do.
| Challenge | What it requires | When to use it |
|---|---|---|
| HTTP-01 | Serving a file at http://domain/.well-known/acme-challenge/<token> |
The default; it needs port 80 reachable from the Internet |
| DNS-01 | Publishing a TXT record at _acme-challenge.domain |
When 80 is not reachable, and compulsory for wildcards |
| TLS-ALPN-01 | Answering on 443 with an ALPN extension | When 80 is closed but 443 is not |
DNS-01 is essential in two cases: wildcard certificates (*.tramontana.example) and servers that do not expose port 80 — and it requires the DNS provider to have an API, or doing it by hand at every renewal, which is not viable.
And here the note on scope from the beginning applies: Nginx is built in 08-01, so for now we use --standalone mode, in which certbot brings up its own temporary server on port 80. Remember that in 06-03 you left 443 open but not 80: it has to be opened first.
$ sudo ufw allow 80/tcp comment 'ACME HTTP-01'
$ sudo certbot certonly --standalone \
-d bookings.tramontana.example \
--email [email protected] \
--agree-tos --no-eff-email
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/bookings.tramontana.example/fullchain.pem
Key is saved at: /etc/letsencrypt/live/bookings.tramontana.example/privkey.pem
This certificate expires on 2026-11-16.The four files it generates, because each server asks for a different one:
| File | Content | Who uses it |
|---|---|---|
privkey.pem |
The private key | Always; strict permissions |
cert.pem |
The leaf certificate only | Older configurations |
chain.pem |
The intermediates only | OCSP stapling |
fullchain.pem |
Leaf + intermediates | The usual one: Nginx, modern Apache |
Serving cert.pem instead of fullchain.pem is a classic mistake: it works in the desktop browser, which usually has the intermediate cached, and fails on mobile clients and in curl. It is detected with openssl s_client by looking at whether the chain is complete.
Renewal, and why you test it
Let's Encrypt certificates last 90 days. That forces automation, which is intentional: an automated and tested renewal fails less often than an annual one done by hand.
$ systemctl list-timers certbot.timer --no-pager
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-08-18 22:47:19 CEST 10h left - - certbot.timer certbot.service
# The test, which runs the WHOLE real process against the CA's staging environment
$ sudo certbot renew --dry-run
Simulating renewal of an existing certificate for bookings.tramontana.example
Congratulations, all simulated renewals succeeded.--dry-run is the most important line in this section. Renewal typically fails because port 80 is closed by a new firewall rule, because a service is occupying that port, or because a --deploy-hook is broken. And it fails silently, 60 days after you made the change, when nobody remembers making it. Running a dry run after every network or firewall change is the discipline that avoids that scenario.
The deploy hook is what reloads the service after renewing:
$ sudo tee /etc/letsencrypt/renewal-hooks/deploy/10-reload >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
systemctl is-active --quiet nginx && systemctl reload nginx
logger -t certbot -p local0.notice "certificate renewed and service reloaded"
EOF
$ sudo chmod 700 /etc/letsencrypt/renewal-hooks/deploy/10-reloadA renewed certificate the service has not reloaded is no use: the process goes on presenting the old one until it restarts. It is another classic silent failure.
On mTLS (mutual authentication), briefly: the server also demands a client certificate. It is excellent for service-to-service communication — much better than an API key — and inadvisable for people, because of the cost of distributing and renewing certificates on every device.
TLS good practice, key custody and expiry monitoring
Configuration
The current criteria, which will be applied in 08-01 when configuring Nginx:
- TLS 1.2 and TLS 1.3 only. SSL 3.0, TLS 1.0 and 1.1 have been withdrawn because of known vulnerabilities and no current client needs them.
- Suites with forward secrecy (ECDHE), so that a future compromise of the private key does not allow traffic captured in the past to be decrypted.
- HSTS, the
Strict-Transport-Securityheader, which tells the browser to always use HTTPS for this domain. Careful: it is hard to reverse, so start with a shortmax-age. - Do not invent the cipher suite list. Use the generator at
ssl-config.mozilla.organd its intermediate profile, which is the reasonable balance between security and compatibility, and review it periodically.
Custody of the private key
$ sudo ls -l /etc/letsencrypt/live/bookings.tramontana.example/privkey.pem
-rw-r----- 1 root ssl-cert 241 Aug 18 11:12 privkey.pem640 root:ssl-cert is the pattern: root writes, the ssl-cert group reads, nobody else. The process that needs the key is added to that group — the key is never made world-readable.
If the private key leaks, generating a new one is not enough: you have to revoke the old one, because it remains valid until it expires and would let whoever holds it impersonate your service:
$ sudo certbot revoke --cert-path /etc/letsencrypt/live/bookings.tramontana.example/cert.pem \
--reason keyCompromise
$ sudo certbot certonly --standalone -d bookings.tramontana.example --force-renewalHonestly about its effectiveness: revocation depends on the client checking it, and many do not, or fail permissively. It is necessary but not sufficient; the response plan has to assume that the leaked key remains usable for a while.
Watching the expiry
The silliest and most frequent production failure is an expired certificate. It happens even with automatic renewal, because automation breaks too. The measure is an independent control that does not trust the renewal mechanism, along the lines of check_backup.sh from 05-08:
$ cat ~/scripts/check_certificate.sh
#!/usr/bin/env bash
# check_certificate.sh - Warns if a TLS certificate expires soon.
# Usage: check_certificate.sh [-d days] [-H host] [-p port]
# Exit: 0 correct | 1 warning (expires soon) | 2 critical (expired or unreachable)
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
readonly LOG_TAG="check-certificate"
main() {
local warn_days="${TRAMONTANA_CERT_DAYS:-20}"
local host="${TRAMONTANA_CERT_HOST:-bookings.tramontana.example}"
local port="${TRAMONTANA_CERT_PORT:-443}"
while getopts ":d:H:p:h" option; do
case "$option" in
d) warn_days="$OPTARG" ;;
H) host="$OPTARG" ;;
p) port="$OPTARG" ;;
h) sed -n '2,4s/^# \?//p' "$0"; return 0 ;;
*) die 64 "invalid option: -$OPTARG" ;;
esac
done
require_command openssl
is_number "$warn_days" || die 64 "the days must be a number: $warn_days"
local end_text
if ! end_text="$(timeout 10 openssl s_client -connect "${host}:${port}" \
-servername "$host" </dev/null 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)"; then
error "could not obtain the certificate from ${host}:${port}"
return 2
fi
[[ -n "$end_text" ]] || { error "empty response from ${host}:${port}"; return 2; }
local end_epoch now_epoch days_left
end_epoch="$(date -d "$end_text" +%s)"
now_epoch="$(date +%s)"
days_left=$(( (end_epoch - now_epoch) / 86400 ))
if (( days_left < 0 )); then
error "EXPIRED $(( -days_left )) days ago: $host"
return 2
fi
if (( days_left <= warn_days )); then
error "expires in ${days_left} days (threshold ${warn_days}): $host"
return 1
fi
log "certificate for $host correct, expires in ${days_left} days"
return 0
}
main "$@"$ chmod +x ~/scripts/check_certificate.sh
$ shellcheck ~/scripts/check_certificate.sh && echo "no warnings"
no warnings
$ ~/scripts/check_certificate.sh; echo "status: $?"
certificate for bookings.tramontana.example correct, expires in 90 days
status: 0The 20-day threshold is not arbitrary: with 90-day certificates, certbot renews at 60 days, so 30 days of margin remain. A warning at 20 means the automatic renewal has failed twice and there are still nearly three weeks to fix it calmly.
And since the script returns 0/1/2 just like health_check.sh, it integrates without friction with the general check and with the monitoring.
Common Mistakes and Tips
- Generating a certificate with no SAN. The
CNis no longer validated. Without-addext "subjectAltName=..."in the CSR and without-copy_extensions copyextwhen self-signing, you get a certificate every client rejects. - Serving
cert.peminstead offullchain.pem. It works in your browser — which has the intermediate cached — and fails on mobiles and incurl. Always verify withopenssl s_clientfrom another machine. - Not running the renewal dry run.
certbot renew --dry-runafter every firewall or web server change. Otherwise you find out 60 days later, when the certificate genuinely expires. - Forgetting the deploy hook. A renewed certificate the service has not reloaded is worth nothing: the process goes on presenting the old one.
- Passing a secret on the command line.
--password=Xis visible inpsto the whole system. Use a file, standard input or the program's credential mechanism. - Decrypting a secret to a file on disk. It leaves recoverable traces. Use
/dev/shm, a pipe, orpass -cwith the temporary clipboard. - Believing that LUKS protects a running server. It protects a stolen or discarded disk, not a compromised system, where the volume is already mounted. Getting that wrong in a report is worse than leaving it out.
- Rotating by withdrawing the old credential before verifying the new one. Create, apply, verify, withdraw. That order, always.
- Losing
pass's GPG key with no backup. It is the master key to all your secrets. Its encrypted backup and its custody are part of the runbook, not a detail. - A tip on method. Record in the runbook, for each secret: where the source of truth is, who can recover it, when it was last rotated and how often it is due. An inventory of secrets with no dates is an inventory nobody maintains.
Exercises
Exercise 1
restic needs its repository password in order to run the nightly backup with no human intervention. Today it is in a 600 file owned by root and read by backup_tramontana.sh. Redesign the solution using pass as the source of truth and systemd's credential mechanism, and explain the recoverability problem that appears and how you resolve it.
Exercise 2
You receive this warning and have to diagnose it:
$ ~/scripts/check_certificate.sh
[2026-11-10 08:00:03] ERROR check-certificate: expires in 6 days (threshold 20): bookings.tramontana.examplecertbot.timer is active and certbot renew has given no visible error in the journal. List the possible causes in order of likelihood, with the command that confirms or rules out each one.
Exercise 3
Marta asks you in writing "for the encryption to protect the guests' data". Write the report with the three layers of encryption you have built — secrets with systemd-creds and pass, disk with LUKS, transit with TLS — stating for each one what it protects and what it does not protect, and which risk to the guests' data remains open despite all three.
Solutions
Solution 1
# 1. The source of truth, with history and synchronisable off the server
$ pass insert -m tramontana/production/restic
Enter contents of tramontana/production/restic and press Ctrl+D when finished:
<repository-password>
repository: /srv/tramontana/backups/restic
rotated: 2026-08-18
# 2. The operational copy, encrypted and tied to the machine
$ pass tramontana/production/restic | head -1 \
| sudo systemd-creds encrypt --name=restic_pass - \
/etc/tramontana/secrets/restic_pass.cred
$ sudo chmod 600 /etc/tramontana/secrets/restic_pass.cred# /etc/systemd/system/tramontana-backup.service.d/override.conf
[Service]
LoadCredentialEncrypted=restic_pass:/etc/tramontana/secrets/restic_pass.credAnd in the script, restic reads the password from a file, which is exactly what the credential hands it:
# In backup_tramontana.sh
readonly RESTIC_PASSWORD_FILE="${CREDENTIALS_DIRECTORY:?this script is run via systemd}/restic_pass"
export RESTIC_PASSWORD_FILE
restic -r "$TRAMONTANA_REPO" backup /home/operator/data /etc/tramontanaThree decisions worth justifying:
RESTIC_PASSWORD_FILEinstead ofRESTIC_PASSWORD: the password does not go through the environment, only the path to the file does.${CREDENTIALS_DIRECTORY:?...}with a message: if the script is run by hand instead of by systemd, it fails immediately and with a clear message, instead of trying to back up without a credential. It is the expansion from 04-02 applied to something real.- systemd mounts the credential in a
tmpfsprivate to the service: neitherluis, norintern, norsvc-tramontanaitself from another process can read it.
The recoverability problem, which is the substance of the exercise: the .cred file is encrypted with a key derived from this machine (/var/lib/systemd/credential.secret). If the server is lost — scenario (c) of the restore in 05-08, or a reinstall after a compromise — that file cannot be decrypted. And without restic's password there is no way to read the backups: you would have the backups and be unable to restore them, which is the worst possible failure.
It is resolved on two fronts:
passis the source of truth, and it lives elsewhere. Thepassrepository is synchronised to a remote (pass git push), and the private GPG key has an encrypted backup on separate physical media. Rebuilding the server means: install, restorepass, and run the pipeline from step 2 again.- The runbook documents it explicitly, with that pipeline written out literally and the location of the GPG key. The runbook is already off the server thanks to the discipline of 05-08.
The general rule to take away: a secret tied to the machine can never be the only copy. The systemd credential is an operational cache; the source of truth is somewhere else and is recoverable by an authorised person.
Solution 2
The fact that certbot renew gave no error is the main clue: you have to distinguish "it renewed and something afterwards failed" from "it did not renew and nobody noticed". The first check separates the two branches:
$ sudo openssl x509 -in /etc/letsencrypt/live/bookings.tramontana.example/cert.pem \
-noout -enddateBranch A — the certificate on disk is new (expires in ~90 days). The renewal worked; the problem is downstream.
- The service was not reloaded (the most likely). The process is still in memory with the old certificate:
If the service's start timestamp is earlier than the renewal, this is it. It is fixed by reloading, and fixed at the root by reviewing the deploy hook.$ ls -l /etc/letsencrypt/renewal-hooks/deploy/ $ sudo journalctl -u certbot --since "40 days ago" | grep -iE 'hook|deploy|reload' $ systemctl show nginx -p ActiveEnterTimestamp - The server points at the wrong file: at a
cert.pemcopied to another path at some point, instead of at the link underlive/thatcertbotupdates.$ grep -rE 'ssl_certificate' /etc/nginx/ - You are looking at another certificate: without
-servername,openssl s_clientreturns the default site. The script itself passes it, but check if you have been testing by hand.
Branch B — the certificate on disk also expires in 6 days. Nothing has been renewed. certbot renew does not fail loudly when the challenge cannot be completed in a non-interactive run, so you have to provoke the error:
Causes in order of likelihood:
- Port 80 is closed. It is cause number one, because any firewall review carries away that rule "that did not look necessary":
$ sudo ufw status verbose | grep -E '^80|http' $ sudo nft list ruleset | grep -E 'dport 80|dport { .*80' - Another process occupies 80 and
--standalonecannot bring up its server:$ sudo ss -tulpn | grep ':80 ' - The timer does not actually run, even though it is
active:
An empty$ systemctl list-timers certbot.timer --no-pager $ sudo journalctl -u certbot.service --since "70 days ago" | tail -20LASTwith an active timer means it has never fired. - DNS: the name no longer resolves to this machine, so the HTTP-01 challenge lands somewhere else:
$ dig +short bookings.tramontana.example $ getent hosts bookings.tramontana.example - The CA's rate limit, if there were many failed attempts. The
--dry-runoutput says so, and it is distinguishable because it uses the staging environment and consumes no quota.
The lesson on method: the warning arrived with 6 days of margin because the threshold was 20 and the control is independent of the renewal mechanism. If the monitoring had consisted of trusting certbot, you would have found out with the service down. That is why verification controls must not share a mechanism with what they verify.
Solution 3
Report for Marta Vidal — Encryption of guests' data on
srv-tramontana18 August 2026Three layers of encryption have been put in place. Each covers a different risk and none replaces the others. I set out what each one protects and does not protect, and the risk that remains open.
Layer 1 — Secrets (systemd-creds + pass) The database password and the backup repository password are no longer in the clear in any configuration file or in any process's environment. They are encrypted on disk and systemd delivers them to the service in a private space in memory. The source of truth is in a manager with history, off the server. Protects: credential leakage through a permission mistake, through a badly made backup, through a log entry or through reading a process's environment. It closes the incident that has been open since the backup with 644 permissions. Does not protect: against an attacker with root privileges on the running machine, who can read the credential in the same way as the service.
Layer 2 — The backup disk (LUKS) The
/srv/tramontana/backupsvolume is encrypted at block level. In addition, theresticrepository has its own encryption, so the backups have two independent layers. Protects: theft or physical loss of the disk, access to the image of the powered-off machine, and data recovery from a disk withdrawn without secure erasure. Does not protect: against an attacker who compromises the running server. For them, the volume is mounted and readable. It is encryption at rest, not in use.Layer 3 — Transit (TLS) The cryptographic material for
bookings.tramontana.examplehas been issued by Let's Encrypt, verified, with automatic renewal tested and independent expiry monitoring warning 20 days ahead. Protects: the reading and modification of booking data on its way across the network, and impersonation of the service. Does not protect: the data once it reaches the server, nor the security of the application itself.Open risk, and it is important that it is on record Guests' data currently travels unencrypted: the application listens over HTTP on port 8080. The certificate is ready and verified, but the component that will present it — the reverse proxy in front of the application — is scheduled for the next phase of the project. Until it is deployed, encryption in transit is not in force, even though the material is ready. Interim mitigation: 8080 is not reachable from the Internet, so the exposure is limited to the internal network.
Residual risk common to all three layers No layer protects against a compromise of the running server with root privileges. The controls from the previous phase act against that scenario: change detection with AIDE, access auditing with
auditd, and the incident response procedure with a rebuild.Compliance. The encryption of personal data in transit and at rest is a GDPR obligation (art. 32), not an optional improvement, and the open risk in the previous point is relevant in that respect. I recommend that the design of key custody and the decision on the timescale for deploying the reverse proxy be reviewed by the data protection officer.
Conclusion
The course's two cryptographic debts are settled, each in its own way. The db_password no longer exists as text in any configuration file: it lives encrypted in a .cred that only systemd can decrypt and only the service can read, with pass as the versioned source of truth synchronised off the server. The backup volume is encrypted with LUKS, with precise knowledge of what that protects and what it does not. And the material for bookings.tramontana.example is issued, verified with openssl, with automatic renewal tested in a dry run and an independent expiry control that warns twenty days ahead. The third open incident is now closed, and you know why LoadCredentialEncrypted is better than EnvironmentFile, why the SAN outranks the CN, and why the order of a rotation is create, apply, verify and withdraw.
One is left. On 18 August at 06:12, unattended-upgrades applied the fix for a TLS CVE to openssl and libssl3t64 — the library that underpins everything you have just built in the second half of this lesson — and left running services still using the old version loaded in memory. An installed patch nobody has genuinely applied is a vulnerability with paperwork. In lesson 06-06: Securing Linux Systems that incident is closed with needrestart and something more ambitious is done: bringing everything you have learned together into an explicit threat model and a coherent security posture. You will reduce the attack surface, harden authentication with PAM — the debt you left outstanding in 05-01 — write an AppArmor profile for the application, apply the kernel hardening sysctl settings and the noexec mounts, raise the systemd-analyze security score with judgement, and finish with a hardening checklist for srv-tramontana that says honestly what is done, what has been consciously accepted and what falls outside an administrator's remit.
Linux Course: From Beginner to System Administrator
Module 1: Introduction to Linux
- What Is Linux?
- History of Linux
- Linux Distributions
- Installing Linux
- First Contact with the System
- The Linux File System Structure
Module 2: Basic Linux Commands
- Introduction to the Command Line
- Getting Help and System Documentation
- Navigating the File System
- File and Directory Operations
- Viewing and Editing Files
- Hard and Symbolic Links
- File Permissions and Ownership
Module 3: Advanced Command-Line Skills
- The Shell Environment: Variables, Aliases and History
- Using Wildcards and Regular Expressions
- Searching Files and Content: find, locate and grep
- Pipes and Redirection
- Text Processing: cut, sort, uniq, sed and awk
- Process Management
- Scheduling Tasks with Cron
- Networking Commands
Module 4: Shell Scripting
- Introduction to Shell Scripting
- Variables and Data Types
- Script Input, Output and Arguments
- Control Structures
- Functions and Libraries
- Debugging and Error Handling
- Production Scripts: Best Practices
Module 5: System Administration
- User and Group Management
- sudo and Special Permissions
- Package Management
- Disk Management
- systemd and Service Management
- System Logs: journald and syslog
- System Monitoring and Performance Tuning
- Backup and Restore
Module 6: Networking and Security
- Network Configuration
- SSH and Remote Access
- Firewalls and Perimeter Security
- Intrusion Detection Systems
- Secrets Management and TLS Certificates
- Securing Linux Systems
Module 7: Advanced Topics
- The Boot Process and System Recovery
- Advanced Diagnostics: strace, perf and eBPF
- Linux Kernel Tuning
- Virtualization with Linux
- Linux Containers and Docker
- Automation with Ansible
- High Availability and Load Balancing
