AlpinaShop has a solid infrastructure and absolutely nobody can use it. The shop is served from a numeric IP address, with no name and no certificate: typing https://34.120.x.x into a browser produces a red security warning, and no customer enters their card details after seeing that. All the work of the module's previous six sections depends on the missing piece: a name and a certificate.
This lesson closes module 3 by putting in place what the customer actually sees. You are going to create the public DNS zone for alpinashop.example, point the domain at the global IP alpinashop-lb-ip, delegate from the registrar and verify it; provision a Google-managed certificate and understand why it gets stuck in PROVISIONING when something is wrong; harden the connection with an SSL policy; add the security headers the Flask application should emit; and verify the result end to end with curl and openssl. We will finish with a checklist covering everything built in the module, from the VPC to the certificate.
Warning. TLS and security header configuration has permanent effects that are hard to reverse — HSTS is the canonical example — and compliance implications if payments are processed. Before applying this configuration to a real production domain, review it with a security professional. And a practical note:
alpinashop.exampleuses the.exampledomain, reserved for documentation; in a real case you would be working with a genuinely registered domain.
Contents
- A functional DNS refresher: just enough to operate
- Cloud DNS: managed zones
- AlpinaShop's public zone, record by record
- Delegation from the registrar and verification with
dig - Private zones for internal resolution
- DNSSEC: what it protects and what it does not
- Google-managed certificates
- Why a certificate gets stuck in
PROVISIONING - Certificate Manager: many domains and wildcards
- Your own certificates and when they make sense
- SSL policies: minimum version and cipher suites
- Security headers emitted by the application
- End-to-end verification
- Module 3 secure publishing checklist
- A functional DNS refresher: just enough to operate
DNS translates names into addresses. Its model is an inverted tree in which each level delegates to the next: the root delegates to .example, which delegates to alpinashop.example, whose authoritative servers answer for everything below it.
This is the complete journey of what happens when a customer types the domain into the browser, and it is worth having in front of you because every piece of the lesson appears in it:
sequenceDiagram
participant N as Browser
participant R as Resolver (8.8.8.8)
participant RT as Root servers
participant TLD as .example servers
participant CD as Cloud DNS<br/>alpinashop-publica
participant LB as Load balancer<br/>alpinashop-lb-ip
N->>R: A record for alpinashop.example?
R->>RT: who knows about .example?
RT-->>R: the .example servers
R->>TLD: who knows about alpinashop.example?
TLD-->>R: NS → ns-cloud-b1.googledomains.com (delegation)
R->>CD: A record for alpinashop.example?
CD-->>R: 34.120.10.5 (+ DNSSEC signature)
R-->>N: 34.120.10.5, kept for the TTL
N->>LB: TLS ClientHello (SNI: alpinashop.example)
LB-->>N: alpinashop-cert certificate + SSL policy
N->>LB: GET / over HTTPS
LB-->>N: 200 + HSTS, CSP, X-Content-Type-Options
Note two points that correspond to two sections of this lesson: the delegation returned by the .example servers (section 4) and the SNI the browser sends, which is what lets one load balancer serve several domains with several certificates (sections 7 and 9).
The record types you need to know:
| Type | What for | Example |
|---|---|---|
| A | Name → IPv4 address | alpinashop.example → 34.120.10.5 |
| AAAA | Name → IPv6 address | alpinashop.example → 2600:1901::… |
| CNAME | An alias for another name | www → alpinashop.example |
| MX | Mail servers, with priority | 10 aspmx.l.google.com. |
| TXT | Free text: domain verification, SPF, DKIM, DMARC | "v=spf1 include:_spf.google.com ~all" |
| NS | Delegation: who is in charge of this zone | Cloud DNS's four servers |
| CAA | Which authorities may issue certificates for the domain | 0 issue "pki.goog" |
| SOA | Zone metadata | Generated automatically |
Three rules that cause most of the problems and are worth keeping in mind:
- There cannot be a CNAME at the zone apex.
alpinashop.examplecannot be a CNAME; it has to be an A or AAAA record. It is a restriction of the protocol itself, because the apex already has SOA and NS records and a CNAME cannot coexist with other records. That is why we reserved a static global IP in 03-01: it is exactly what is needed at the apex. - The TTL governs propagation. When a resolver caches an answer, it keeps it for as long as the TTL says. If the TTL is 24 hours and you change the IP, some clients will keep going to the old one for 24 hours. Before a migration, lower the TTL to 60-300 seconds days in advance, and raise it again once everything is stable. It is the most valuable piece of advice in the whole section.
- Absolute names end with a dot.
alpinashop.example.with a trailing dot is an absolute name; without it, some tools append the local search domain. Cloud DNS is strict: it requires the dot.
- Cloud DNS: managed zones
Cloud DNS is Google's authoritative DNS service, with global anycast, very low latency and a 100 % availability service level agreement. Its units are managed zones:
| Type | Visible from | Use at AlpinaShop |
|---|---|---|
| Public | The whole internet | alpinashop.example: the shop |
| Private | Only the VPCs you authorise | interna.alpinashop.example: internal services |
| Forwarding | Delegates queries to your own servers | Hybrid connectivity (07-03) |
| Peering | Shares resolution between VPCs | Shared VPC (07-03) |
Mind the distinction between registering a domain and hosting its DNS: they are different things. The registrar is where you buy the domain (Cloud Domains, or any other); Cloud DNS is where its records are hosted. You can perfectly well have the domain with one registrar and the DNS at Google, and in fact that is the most common case.
- AlpinaShop's public zone, record by record
gcloud config set project alpinashop-prod
gcloud services enable dns.googleapis.com
gcloud dns managed-zones create alpinashop-publica \
--dns-name="alpinashop.example." \
--description="Public zone for the AlpinaShop shop" \
--visibility=public \
--dnssec-state=on
# The name servers to declare at the registrar
gcloud dns managed-zones describe alpinashop-publica \
--format="value(nameServers)"That last command returns something like ns-cloud-b1.googledomains.com. and three more. Note them down: they are the piece that goes into the registrar.
Now the records. Remember the global IP reserved in 03-01:
IP_LB=$(gcloud compute addresses describe alpinashop-lb-ip --global --format="value(address)")
echo "$IP_LB"
# Zone apex: an A record is mandatory (it cannot be a CNAME)
gcloud dns record-sets create alpinashop.example. \
--zone=alpinashop-publica --type=A --ttl=300 --rrdatas="$IP_LB"
# www: a CNAME to the apex. One single place to change if the IP changes.
gcloud dns record-sets create www.alpinashop.example. \
--zone=alpinashop-publica --type=CNAME --ttl=300 --rrdatas="alpinashop.example."
# imagenes: the SAME IP. The URL map from 03-02 already routes by Host and path.
gcloud dns record-sets create imagenes.alpinashop.example. \
--zone=alpinashop-publica --type=A --ttl=3600 --rrdatas="$IP_LB"
# Corporate email
gcloud dns record-sets create alpinashop.example. \
--zone=alpinashop-publica --type=MX --ttl=3600 \
--rrdatas="1 aspmx.l.google.com.,5 alt1.aspmx.l.google.com.,10 alt2.aspmx.l.google.com."
# SPF, so that the shop's email does not end up in spam
gcloud dns record-sets create alpinashop.example. \
--zone=alpinashop-publica --type=TXT --ttl=3600 \
--rrdatas='"v=spf1 include:_spf.google.com ~all"'
# CAA: only Google can issue certificates for this domain
gcloud dns record-sets create alpinashop.example. \
--zone=alpinashop-publica --type=CAA --ttl=3600 \
--rrdatas='0 issue "pki.goog"'Design decisions that deserve an explanation:
imagenes.alpinashop.examplepoints at the same IP. No second load balancer is needed: the URL map routes byHostand by path. One load balancer, one certificate, one place to apply Cloud Armor. A subdomain does not imply new infrastructure.wwwis a CNAME to the apex, so an IP change is made in a single record.- A TTL of 300 seconds on what can change (the apex,
www) and 3600 on what is stable (MX, TXT). Before a migration, lower it to 60. - The CAA record is a cheap lock: it stops another certificate authority issuing a certificate for your domain, even if it manages to fool somebody. If you later use Let's Encrypt, you will have to add
0 issue "letsencrypt.org".
For several atomic changes, Cloud DNS offers transactions:
gcloud dns record-sets transaction start --zone=alpinashop-publica
gcloud dns record-sets transaction add "$IP_LB" \
--name=api.alpinashop.example. --ttl=300 --type=A --zone=alpinashop-publica
gcloud dns record-sets transaction add "$IP_LB" \
--name=blog.alpinashop.example. --ttl=300 --type=A --zone=alpinashop-publica
gcloud dns record-sets transaction execute --zone=alpinashop-publica
gcloud dns record-sets list --zone=alpinashop-publica \
--format="table(name, type, ttl, rrdatas.list())"
- Delegation from the registrar and verification with
dig
digCreating the zone in Cloud DNS does nothing by itself. The internet carries on asking whichever servers the registrar names. Delegation consists of going to the registrar's dashboard and replacing the name servers with Cloud DNS's four.
And here is the most frequent mistake: changing name servers takes time. The delegation at the level above has its own TTL, typically 24 to 48 hours. During that time, some resolvers around the world will see the new configuration and others the old one, so the shop will work "sometimes". That is not a fault: it is how DNS works.
Verification, in this order:
# 1) Which servers does the level above say are authoritative?
dig NS alpinashop.example +short
# 2) Ask Cloud DNS DIRECTLY: takes propagation out of the equation
dig @ns-cloud-b1.googledomains.com A alpinashop.example +short
# 3) What any public resolver sees
dig @8.8.8.8 A alpinashop.example +short
dig @1.1.1.1 A alpinashop.example +short
# 4) The complete chain, from the root
dig +trace alpinashop.example
# 5) The www CNAME
dig CNAME www.alpinashop.example +shortReading these commands is what turns an hour of frustration into a one-minute diagnosis:
- If (2) works and (3) does not, the configuration is correct and only propagation is missing. Wait.
- If (2) does not work, the record is wrong in Cloud DNS. Fix it yourself.
- If (1) returns the old servers, the delegation has not been done or has not propagated yet.
- Private zones for internal resolution
Inside alpinashop-vpc, Google already resolves the VMs' internal names automatically. But it is far better for the application to connect to pedidos.interna.alpinashop.example than to 10.10.1.5: the day the IP changes — a replica promoted, a migration — there is no code to touch.
gcloud dns managed-zones create alpinashop-interna \
--dns-name="interna.alpinashop.example." \
--description="AlpinaShop internal resolution" \
--visibility=private \
--networks=alpinashop-vpc
IP_SQL=$(gcloud sql instances describe alpinashop-pedidos \
--format="value(ipAddresses[0].ipAddress)")
gcloud dns record-sets create pedidos.interna.alpinashop.example. \
--zone=alpinashop-interna --type=A --ttl=60 --rrdatas="$IP_SQL"
gcloud dns record-sets create cache.interna.alpinashop.example. \
--zone=alpinashop-interna --type=A --ttl=60 --rrdatas="10.10.1.20"Properties of private zones:
- They only resolve from the authorised VPCs. From the internet,
pedidos.interna.alpinashop.exampledoes not exist. You leak nothing about your internal topology. - A low TTL (60 s) on purpose: these are names designed precisely to change without drama.
- The same zone can be defined in several VPCs. Sharing it between projects moves into the territory of Shared VPC and peering, which is covered in 07-03.
- DNSSEC: what it protects and what it does not
DNSSEC cryptographically signs DNS answers, so that a resolver can verify that the answer is genuine and has not been fabricated by an attacker. It protects against DNS cache poisoning: somebody making your resolver believe that alpinashop.example is at the attacker's IP.
# We already enabled it when creating the zone; otherwise, update it
gcloud dns managed-zones update alpinashop-publica --dnssec-state=on
# Get the DS record that has to be declared AT THE REGISTRAR
gcloud dns dns-keys list --zone=alpinashop-publica \
--format="table(keyTag, type, algorithm, digests[0].digest)"
# Verify that the chain of trust is complete
dig +dnssec alpinashop.example | grep -E "RRSIG|ad;"The step almost everybody forgets is the second one: enabling DNSSEC in Cloud DNS is not enough. You have to take the DS record to the registrar so that the level above signs the delegation. Without that step, you have signed your zone but nobody can verify the signature, and it is useless.
What DNSSEC does not protect, so as not to confuse layers:
- It encrypts nothing: DNS queries are still visible. That is solved by DoH and DoT, which are a client matter.
- It does not protect the website's content: that is TLS.
- It does not stop anybody registering
alpinashop-tienda.examplefor phishing.
And a serious operational warning: badly configured DNSSEC makes the domain invisible, not merely insecure. If the registrar's DS record does not match the zone's keys, validating resolvers reject the answer and your domain disappears for part of the internet. Change it carefully and always verify afterwards.
- Google-managed certificates
A TLS certificate certifies that the server answering for alpinashop.example really is who it says it is, and it allows the connection to be encrypted. Google issues and renews them free and automatically.
gcloud compute ssl-certificates create alpinashop-cert \
--domains="alpinashop.example,www.alpinashop.example,imagenes.alpinashop.example" \
--global
# Associate it with the HTTPS proxy created in 03-02
gcloud compute target-https-proxies update alpinashop-https-proxy \
--ssl-certificates=alpinashop-cert --global
# Follow the provisioning
gcloud compute ssl-certificates describe alpinashop-cert --global \
--format="yaml(name, managed.status, managed.domainStatus)"The managed.domainStatus field gives the state per domain, which is what you need in order to debug:
| State | Meaning | What to do |
|---|---|---|
PROVISIONING |
In progress | Wait, up to about 60 minutes in the normal case |
FAILED_NOT_VISIBLE |
The domain does not resolve to the load balancer's IP | The usual case. Check the A record |
FAILED_CAA_CHECKING |
There is a CAA record that does not allow Google to issue | Add 0 issue "pki.goog" |
FAILED_RATE_LIMITED |
Too many attempts | Wait; do not delete and recreate in a loop |
ACTIVE |
Ready | Nothing |
How Google validates the domain, which is the key to understanding the failures: it checks that the name resolves to the IP of the load balancer the certificate is attached to. In other words, the correct order is DNS first, certificate afterwards. If you create the certificate before the A record has propagated, it will sit there waiting.
Advantages and limits of managed certificates:
| Advantages | Limits |
|---|---|
| Free | Up to 100 domains per certificate |
| Automatic renewal, with no surprises | No wildcards (*.alpinashop.example) |
| No private key files to look after | Requires the domain to point at the load balancer already |
| Several can be attached to the same proxy | Only for Google load balancers |
- Why a certificate gets stuck in
PROVISIONING
PROVISIONINGThis deserves its own section because it is, by a distance, the most frequent problem when publishing on Google Cloud. The list of causes, ordered by likelihood:
- The A record does not exist or points at a different IP. By far the most common error.
- DNS has not propagated yet. Correct but recent; you have to wait.
- The load balancer has no forwarding rule on 443. Google validates through the load balancer itself; if 443 is not published, it cannot.
- A CAA record blocks Google. If you had a CAA from another authority, you have to add
pki.goog. - The certificate is not attached to the HTTPS proxy. With no attachment, validation is never triggered.
- It has been deleted and recreated many times, hitting a rate limit. Patience is the solution.
Diagnosis in the right order:
# 1) Where does the domain actually resolve to?
dig A alpinashop.example +short
# 2) What is the load balancer's IP?
gcloud compute addresses describe alpinashop-lb-ip --global --format="value(address)"
# The two values MUST match.
# 3) Is 443 published?
gcloud compute forwarding-rules list --global \
--format="table(name, IPAddress, portRange, target)"
# 4) Is the certificate attached to the proxy?
gcloud compute target-https-proxies describe alpinashop-https-proxy --global \
--format="value(sslCertificates)"
# 5) Is there a CAA getting in the way?
dig CAA alpinashop.example +shortAnd the advice that saves half a day: when you change the DNS to fix it, do not delete the certificate. Google retries validation periodically on its own. Deleting and recreating resets the clock and brings you closer to the rate limit.
- Certificate Manager: many domains and wildcards
When classic certificates fall short — wildcards, hundreds of domains, deployment across several regions — the tool is Certificate Manager. Its validation mechanism is different: it does not require the domain to point at the load balancer already, but validates through an authorisation DNS record. That allows the certificate to be issued before the traffic is migrated, which is exactly what you want in a real migration.
gcloud services enable certificatemanager.googleapis.com
# 1) DNS authorisation: generates a CNAME record that has to be created
gcloud certificate-manager dns-authorizations create auth-alpinashop \
--domain="alpinashop.example"
gcloud certificate-manager dns-authorizations describe auth-alpinashop \
--format="value(dnsResourceRecord.name, dnsResourceRecord.data)"
# 2) Create that CNAME in the zone
gcloud dns record-sets create "_acme-challenge.alpinashop.example." \
--zone=alpinashop-publica --type=CNAME --ttl=300 \
--rrdatas="<value returned by the previous command>"
# 3) A WILDCARD certificate: it covers any subdomain
gcloud certificate-manager certificates create cert-alpinashop-comodin \
--domains="alpinashop.example,*.alpinashop.example" \
--dns-authorizations=auth-alpinashop
# 4) Certificate map and its entries
gcloud certificate-manager maps create mapa-alpinashop
gcloud certificate-manager maps entries create entrada-principal \
--map=mapa-alpinashop \
--certificates=cert-alpinashop-comodin \
--hostname="alpinashop.example"
gcloud certificate-manager maps entries create entrada-comodin \
--map=mapa-alpinashop \
--certificates=cert-alpinashop-comodin \
--hostname="*.alpinashop.example"
# 5) Attach the map to the proxy (it replaces --ssl-certificates)
gcloud compute target-https-proxies update alpinashop-https-proxy \
--certificate-map=mapa-alpinashop --global| Classic managed certificate | Certificate Manager | |
|---|---|---|
| Wildcards | No | Yes |
| Validation | The domain must already point at the load balancer | An authorisation DNS record, before migrating |
| Scale | Up to 100 domains | Thousands |
| Complexity | One command | Authorisation + certificate + map + entry |
| Recommendation | AlpinaShop today | When a wildcard or a zero-downtime migration is needed |
For AlpinaShop, the classic certificate with three names is enough. If tomorrow every partner brand had its own subdomain, Certificate Manager's wildcard would be the answer.
- Your own certificates and when they make sense
You can also upload a certificate issued by another authority:
gcloud compute ssl-certificates create alpinashop-cert-propio \
--certificate=cadena-completa.pem \
--private-key=clave-privada.pem \
--globalLegitimate cases: extended validation (EV) certificates, certificates issued by the corporate authority, or specific contractual requirements. In exchange you take on three things: looking after the private key (Secret Manager, 03-06), renewing before it expires — the classic 3 a.m. incident — and being warned before expiry. If there is no specific reason, the managed certificate is the better decision, precisely because it removes those three responsibilities.
- SSL policies: minimum version and cipher suites
By default, the load balancer accepts old TLS versions so as not to leave anybody out. For a shop processing payments, that is not acceptable: PCI DSS requires TLS 1.2 as a minimum.
gcloud compute ssl-policies create pol-ssl-alpinashop \
--profile=MODERN \
--min-tls-version=1.2 \
--description="TLS 1.2 minimum, modern profile"
gcloud compute target-https-proxies update alpinashop-https-proxy \
--ssl-policy=pol-ssl-alpinashop --globalThe available profiles:
| Profile | Cipher suites | Compatibility | When |
|---|---|---|---|
COMPATIBLE |
All, including the old ones | Maximum | Only if you have very old clients |
MODERN |
The currently recommended ones | Very good | AlpinaShop's choice |
RESTRICTED |
Only those required by strict regulations | Lower | Explicit compliance requirements |
CUSTOM |
The ones you list | Whatever you decide | Very specific cases |
# See which suites each profile enables before deciding
gcloud compute ssl-policies list-available-featuresHow to decide without breaking anything: MODERN with a TLS 1.2 minimum leaves out genuinely old browsers (Internet Explorer on Windows earlier than 7, Android 4.x). For a mountaineering gear shop in 2026, that traffic is residual and probably not even human. If it worries you, measure first: the load balancer log records the TLS version negotiated, so you can know exactly how many real customers it would affect.
Two more proxy adjustments, while we are here:
# HTTP/3 (QUIC): lower connection setup latency, especially on mobile
gcloud compute target-https-proxies update alpinashop-https-proxy \
--quic-override=ENABLE --globalAnd remember that the HTTP to HTTPS redirect was already done in 03-02, with a separate URL map on port 80 returning a permanent 301. Without that redirect, the HSTS header from the next section would never reach the customers who type the domain with no protocol.
- Security headers emitted by the application
The load balancer encrypts the connection, but there are protections only the application can enable, because they are instructions for the browser.
# seguridad.py — AlpinaShop's security headers
from flask import request
CSP = "; ".join([
"default-src 'self'",
"img-src 'self' https://imagenes.alpinashop.example data:",
"script-src 'self' https://www.google.com/recaptcha/",
"style-src 'self' 'unsafe-inline'",
"frame-ancestors 'none'", # equivalent to X-Frame-Options: DENY
"form-action 'self'",
"base-uri 'self'",
"upgrade-insecure-requests",
])
def register_security_headers(app):
@app.after_request
def headers(response):
# HSTS: forces the browser to use HTTPS for a year.
# 'preload' requests inclusion in the browsers' preloaded list.
# CAREFUL: it is practically irreversible.
response.headers["Strict-Transport-Security"] = \
"max-age=31536000; includeSubDomains"
# Stops the browser "guessing" the content type, which prevents a
# .txt uploaded by a user from being executed as a script.
response.headers["X-Content-Type-Options"] = "nosniff"
# Do not leak the full URL (which may carry a SKU or an order id)
# to external domains.
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Deny up front browser APIs the shop does not use.
response.headers["Permissions-Policy"] = \
"geolocation=(), microphone=(), camera=(), payment=(self)"
# Content security policy: the strongest defence against XSS.
# It is deployed FIRST in report-only mode for weeks.
if request.path.startswith("/admin"):
response.headers["Content-Security-Policy"] = CSP
else:
response.headers["Content-Security-Policy-Report-Only"] = CSP
return response| Header | What it prevents | The catch |
|---|---|---|
Strict-Transport-Security |
Downgrade to HTTP and man-in-the-middle attacks | Almost irreversible: for the duration of max-age, browsers refuse to use HTTP on your domain |
Content-Security-Policy |
XSS, injection of third-party scripts | It breaks the site if deployed without measuring. Use Report-Only first |
X-Content-Type-Options |
Execution of user-uploaded content | None. Always set it |
Referrer-Policy |
Leaking internal URLs to third parties | None |
Permissions-Policy |
Use of browser APIs by third-party scripts | List only what you use |
HSTS deserves a paragraph of its own. Start with max-age=300 for a few days, raise it to a day, then to a year, and only then consider preload. The reason: once a browser has seen the header, it will refuse to connect over HTTP to your domain for the whole max-age, even if you want it to. And preload goes further still: it puts your domain in a list compiled into the browsers themselves, which takes months to get out of. If you enable includeSubDomains and it then turns out that an internal subdomain has no certificate, that subdomain becomes unreachable.
In the same way, CSP is deployed in Content-Security-Policy-Report-Only first, the violation reports are collected for weeks and the policy is adjusted until there are no false positives left. It is exactly the same discipline as Cloud Armor's preview mode in 03-05: measure before enforcing.
- End-to-end verification
# 1) Full handshake, certificate chain and TLS version
curl -vI https://alpinashop.example/ 2>&1 | \
grep -E "SSL connection|subject:|issuer:|expire|HTTP/"
# 2) Certificate detail: names covered and dates
echo | openssl s_client -connect alpinashop.example:443 \
-servername alpinashop.example 2>/dev/null | \
openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# 3) Are old TLS versions really rejected?
openssl s_client -connect alpinashop.example:443 -tls1_1 2>&1 | grep -i "alert\|failure"
# It must FAIL. If it connects, the SSL policy is not applied.
# 4) Does HTTP redirect to HTTPS?
curl -sI http://alpinashop.example/ | grep -E "HTTP/|location"
# 5) Are the security headers there?
curl -sI https://alpinashop.example/ | \
grep -iE "strict-transport|content-security|x-content-type|referrer-policy"
# 6) Does the images subdomain go to the bucket and with caching? (03-03)
curl -sI https://imagenes.alpinashop.example/productos/MOCH-2210/web/frontal-800.a3f9c1.webp | \
grep -iE "HTTP/|age:|cache-control|via:"
# 7) Does Cloud Armor block what it should? (03-05, from outside the office)
curl -s -o /dev/null -w "%{http_code}\n" \
"https://alpinashop.example/buscar?q=%27%20OR%201%3D1--"What each one should produce:
| Check | Expected result |
|---|---|
| 1 | TLSv1.3, issuer Google Trust Services, HTTP/2 200 |
| 2 | subjectAltName with the three names; expiry months away |
| 3 | Connection failure. If it connects, check the SSL policy |
| 4 | 301 with location: https://alpinashop.example/ |
| 5 | All four headers present |
| 6 | 200, age greater than zero on the second request, via: 1.1 google |
| 7 | 403 |
Complementary external tools, useful for the report to management: SSL Labs for an overall grade on the TLS configuration, and Security Headers for the headers. Neither replaces checking it yourself, but they produce a presentable document.
- Module 3 secure publishing checklist
This is the operational summary of everything built. Use it before every publication of a new service.
Network (03-01)
- [ ] VPC in custom mode, with planned subnets and no CIDR overlaps.
- [ ] No VM with a public IP unless explicitly justified; egress through Cloud NAT.
- [ ] SSH only from the IAP range
35.235.240.0/20; never0.0.0.0/0on port 22. - [ ] A rule allowing
130.211.0.0/22and35.191.0.0/16towards the application's port. - [ ] An explicit denial at the end, with logging enabled.
- [ ] Cloud SQL on a private IP, through private services access.
- [ ] Private Google Access enabled on the subnets with no internet egress.
Load balancing (03-02)
- [ ] A static global IP reserved.
- [ ] A shallow health check that does not query the database.
- [ ] Named port declared on the instance group.
- [ ] Connection draining configured.
- [ ]
max-rate-per-instancemeasured with a load test, not picked by eye. - [ ] Load balancer logging enabled.
- [ ] HTTP to HTTPS redirect.
Caching (03-03)
- [ ] CDN enabled on the backend bucket and on the backend service.
- [ ] An appropriate cache mode; never
FORCE_CACHE_ALLover user content. - [ ]
utm_*,gclidandfbclidparameters excluded from the cache key. - [ ] Immutable object names with a long TTL; HTML with a short TTL.
- [ ] Negative caching with the 5xx at
0. - [ ]
serve-while-staleenabled.
Identity (03-04)
- [ ] No basic role (
Owner,Editor) in production. - [ ] Permissions granted to groups, not to people.
- [ ] One service account per workload; never the default Compute one.
- [ ] Zero downloaded JSON keys.
- [ ] Exceptional access with an expiry condition.
- [ ] IAP with
tunnelResourceAccessorfor SSH andhttpsResourceAccessorfor internal things.
WAF (03-05)
- [ ] Security policy attached to the backend service.
- [ ] Office IP exemption at the lowest priority.
- [ ] OWASP rules at sensitivity 1, after at least a week in preview.
- [ ] Rate limiting on the sign-in form and on the search box.
- [ ]
VERBOSElogging and an alert on the volume of denied requests.
Secrets and encryption (03-06)
- [ ] No secret in metadata, in Git, in container images or in logs.
- [ ] Secrets in Secret Manager, with permissions at secret level.
- [ ] A written rotation procedure; disable before destroying.
- [ ] CMEK where compliance requires it, with permission for the service agent.
DNS and TLS (03-07)
- [ ] Public zone in Cloud DNS, with delegation verified with
dig. - [ ] Apex A record pointing at the load balancer's IP.
- [ ] DNSSEC enabled and the DS record declared at the registrar.
- [ ] CAA record restricting who can issue certificates.
- [ ] Managed certificate in the
ACTIVEstate, attached to the HTTPS proxy. - [ ] SSL policy with a TLS 1.2 minimum and the
MODERNprofile. - [ ] HSTS, CSP,
X-Content-Type-OptionsandReferrer-Policyin the responses. - [ ] End-to-end verification with
curlandopensslpassed.
This list is a solid starting point, not a certificate of conformity. Before publishing a service that handles personal data or payments, the final review is done by a security and compliance professional. Security best practices are picked up again cross-cuttingly in 07-04 and governance at scale in 07-07.
Common Mistakes and Tips
- Trying a CNAME at the zone apex. The protocol does not allow it. An A record to the static global IP.
- Not lowering the TTL before a migration. With a 24-hour TTL, the change takes a day to be seen. Lower it to 60 in advance.
- Forgetting the trailing dot in Cloud DNS names.
- Creating the certificate before the DNS record. It gets stuck in
PROVISIONING. DNS first, certificate afterwards. - Deleting and recreating the certificate out of impatience. It resets the clock and brings the rate limit closer. Google retries on its own.
- Enabling DNSSEC without declaring the DS record at the registrar. It is useless; and if the DS does not match, the domain disappears.
- Setting a CAA without including
pki.goog. The managed certificate will never be issued. - Expecting a wildcard from a classic managed certificate. It does not support them: that is Certificate Manager.
- Enabling HSTS with
preloadfrom day one. It is practically irreversible. Raise themax-agein phases. - Deploying CSP without
Report-Only. It breaks the site. The same discipline as Cloud Armor's preview mode. - Testing only from the browser. It caches DNS and certificates aggressively. Use
digandopenssl s_client. - Uploading your own certificate and forgetting the renewal. It is the classic middle-of-the-night incident. If there is no real reason, use the managed one.
- Tip: use a staging subdomain (
pre.alpinashop.example) pointing at a pre-production load balancer. Rehearsing the whole publication there costs little and avoids surprises. - Tip: set a certificate expiry alert in Cloud Monitoring (06-04), even for managed ones. A renewal can fail if the DNS changed.
- Tip: manage the DNS zone as code with Terraform (06-07). A record added by hand and left undocumented is a guaranteed mystery.
Exercises
Exercise 1 — Publishing a new subdomain end to end
AlpinaShop launches a blog at blog.alpinashop.example, served by a new backend service bs-blog that already exists. Write all the necessary steps, in order, from DNS through to verification, including what has to be touched from the module's previous lessons.
Exercise 2 — Migrating the domain from an old provider
alpinashop.example is today on traditional hosting, with the DNS at the registrar and a TTL of 86,400 seconds. It has to be migrated to Google Cloud without the shop stopping working for a single minute and without losing any email. Write the plan with its timeline.
Exercise 3 — Diagnosing a broken publication
Marta has followed the lesson and https://alpinashop.example gives ERR_SSL_PROTOCOL_ERROR in the browser. She gathers this data:
dig A alpinashop.example +short→34.120.10.5.gcloud compute addresses describe alpinashop-lb-ip --global→34.120.10.5.gcloud compute ssl-certificates describe alpinashop-cert --global→managed.status: ACTIVE.curl -I http://alpinashop.example/→ a correct301towards HTTPS.gcloud compute forwarding-rules list --globalshows only one rule, on port 80.
Identify the cause and write the commands that fix it.
Solutions
Solution 1
# 1) DNS: an A record for the subdomain to the SAME global IP
IP_LB=$(gcloud compute addresses describe alpinashop-lb-ip --global --format="value(address)")
gcloud dns record-sets create blog.alpinashop.example. \
--zone=alpinashop-publica --type=A --ttl=300 --rrdatas="$IP_LB"
# 2) Verify that it resolves BEFORE touching the certificate
dig @8.8.8.8 A blog.alpinashop.example +short
# 3) A NEW certificate including the subdomain (the existing one cannot be edited)
gcloud compute ssl-certificates create alpinashop-cert-v2 \
--domains="alpinashop.example,www.alpinashop.example,imagenes.alpinashop.example,blog.alpinashop.example" \
--global
# 4) Attach BOTH to the proxy: the old one keeps serving while the new one provisions
gcloud compute target-https-proxies update alpinashop-https-proxy \
--ssl-certificates=alpinashop-cert,alpinashop-cert-v2 --global
# 5) Wait for ACTIVE
watch -n 60 'gcloud compute ssl-certificates describe alpinashop-cert-v2 \
--global --format="value(managed.status)"'
# 6) URL map: route the new Host (03-02)
gcloud compute url-maps add-path-matcher alpinashop-url-map \
--path-matcher-name=matcher-blog \
--default-service=bs-blog \
--new-hosts=blog.alpinashop.example
# 7) Remove the old certificate once the new one is active
gcloud compute target-https-proxies update alpinashop-https-proxy \
--ssl-certificates=alpinashop-cert-v2 --global
gcloud compute ssl-certificates delete alpinashop-cert --global --quietWhat else has to be touched from the previous lessons:
- 03-05: rule 2300 rejects requests with a
Hostthat is not in the list. You have to addblog.alpinashop.exampleto that expression, or the blog will return 404 from the WAF and nobody will know why. - 03-03: decide
bs-blog's cache policy. A blog is almost static content:CACHE_ALL_STATICwith ans-maxageof several minutes is a big improvement. - 03-01: if
bs-blogpoints at new instances, they need the network tag that allows the health checks. - 03-04: whoever publishes on the blog needs permissions, and those permissions go to a group.
The key step is number 4: attaching both certificates to the proxy simultaneously. The load balancer supports several and picks by SNI, so the site keeps working while the new one is validated. Replacing the certificate outright would leave the shop with no valid certificate during provisioning.
Solution 2
T-7 days — Prepare and lower the TTL.
# At the CURRENT provider: lower the TTL of every record to 300 s.
# This step takes up to 86,400 s to take effect: hence a week beforehand.All the existing records are inventoried: A, MX, TXT (SPF, DKIM, DMARC, third-party service verifications), CNAME. Losing a verification TXT breaks services silently; losing the MX records loses email, which is unrecoverable.
T-3 days — Build the zone in Cloud DNS.
gcloud dns managed-zones create alpinashop-publica \
--dns-name="alpinashop.example." --visibility=public
# Replicate ALL the records from the inventory, with a TTL of 300.
# Verify against Cloud DNS directly, without delegating yet
NS=$(gcloud dns managed-zones describe alpinashop-publica --format="value(nameServers[0])")
dig @"$NS" A alpinashop.example +short
dig @"$NS" MX alpinashop.example +short
dig @"$NS" TXT alpinashop.example +shortAt this point the new zone is a faithful copy, but nobody is using it yet. The A record still points at the old hosting: that is deliberate.
T-1 day — The certificate in advance. With Certificate Manager and a DNS authorisation the certificate can be issued before the domain points at Google. It is exactly the scenario that validation exists for, and it avoids the window in which the domain already points at the load balancer but there is still no certificate.
T-0 — Delegate. Change the name servers at the registrar to Cloud DNS's. For 24-48 hours the two configurations will coexist; since they are identical apart from whatever is changed later, the user notices nothing. Keep the old hosting switched on for that whole time.
T+2 days — Change the destination. With the delegation already propagated and the TTL at 300 seconds, the A record is updated to the load balancer's IP. The real traffic switch happens now, with a five-minute window and the ability to undo it in five minutes.
gcloud dns record-sets update alpinashop.example. \
--zone=alpinashop-publica --type=A --ttl=300 --rrdatas="$IP_LB"T+3 days — Stabilise. Verify that no traffic is still reaching the old hosting (its logs will tell you), raise the TTLs to normal values, enable DNSSEC with its DS record, and only then decommission the hosting.
The idea to hold on to: the change of who resolves (T-0) is separated from the change of where it points (T+2). If they are done at once and something fails, you do not know which of the two caused it and undoing it takes 48 hours.
Solution 3
The cause is in the last piece of data: there is only one forwarding rule, on port 80. The 443 rule is missing. The certificate is ACTIVE (Google validated over HTTP), the DNS is correct and the redirect works — hence the 301 — but when the browser follows that redirect to https://, there is nothing listening on 443. The ERR_SSL_PROTOCOL_ERROR error is exactly that: there is no TLS dialogue on the other side.
# 1) Confirm that the HTTPS proxy exists
gcloud compute target-https-proxies list --global \
--format="table(name, urlMap, sslCertificates.list())"
# 2) Create the missing forwarding rule
gcloud compute forwarding-rules create alpinashop-fr-https \
--global \
--address=alpinashop-lb-ip \
--target-https-proxy=alpinashop-https-proxy \
--ports=443 \
--load-balancing-scheme=EXTERNAL_MANAGED
# 3) Wait 5-10 minutes for propagation and verify
curl -vI https://alpinashop.example/ 2>&1 | grep -E "SSL connection|HTTP/"If step 1 revealed that the HTTPS proxy does not exist either, it would have to be created first:
gcloud compute target-https-proxies create alpinashop-https-proxy \
--url-map=alpinashop-url-map \
--ssl-certificates=alpinashop-cert \
--ssl-policy=pol-ssl-alpinashop \
--globalThe diagnostic lesson: a certificate in ACTIVE proves that Google was able to validate the domain, not that the site serves HTTPS. They are two different things and it is easy to confuse them. Faced with a publishing failure, walk the complete 03-02 chain from the outside in: forwarding rule → proxy → certificate → URL map → backend service → backend → health check. The broken link always turns up.
Conclusion
AlpinaShop is published. Customers type alpinashop.example into the browser, see the padlock and buy. Behind that trivial gesture lie seven lessons of work.
In this last one you have gone over DNS in just enough depth to operate it: the record types, the impossibility of a CNAME at the apex — which is the whole reason for the static global IP — the role of the TTL in any migration and the habit of lowering it in advance. You have created the alpinashop-publica public zone with the apex A record pointing at alpinashop-lb-ip, the www CNAME, the imagenes record to the same IP — because a subdomain does not imply new infrastructure when the URL map routes by Host — the MX records, the SPF and a CAA record restricting who can issue certificates. You have delegated from the registrar and you know how to diagnose with dig whether the problem is yours or is propagation. You have created the alpinashop-interna private zone so that the application stops knowing IP addresses by heart, and you have enabled DNSSEC without forgetting the step almost everybody forgets: the DS record at the registrar.
You have provisioned a Google-managed certificate and, above all, you know why it gets stuck in PROVISIONING and in which order to look at the six possible causes, starting with the one that explains most of them: the A record. You know the limits of managed certificates and you know that wildcards and zero-downtime migrations are Certificate Manager's territory, with its DNS authorisation validation that allows issuing before moving the traffic. You have hardened the connection with the pol-ssl-alpinashop policy (MODERN profile, TLS 1.2 minimum) and you have added the headers only the application can emit — HSTS, CSP, X-Content-Type-Options, Referrer-Policy, Permissions-Policy — with the same discipline you already applied to the WAF: Report-Only first, measure, and only then enforce. And you have verified the result end to end with curl and openssl s_client, checking not only that HTTPS works, but that TLS 1.1 fails, that the images subdomain returns Age and that Cloud Armor is still blocking what it should.
With the final checklist, module 3 is closed as a unit: a segmented network with no public IPs, a global load balancer with healthy health checks, caching at the edge with a well-calculated key, minimal permissions by group and with no downloaded keys, a WAF calibrated with preview mode, secrets out of the code and keys managed where they need to be, and at last a name and a certificate. AlpinaShop is on the internet and it is protected.
And now another story begins. That working shop generates data every second: orders arriving in alpinashop-pedidos, visits recorded in the load balancer logs, clicks on product pages, searches that find nothing, images served from the CDN, shopping carts abandoned in Firestore. Right now, all of that piles up with nobody looking at it. Marta knows how many instances are running, but nobody knows which backpack sells best in Catalonia, or whether the autumn campaign worked, or why 30 % of shopping carts are abandoned at the shipping step. Lucía, who so far has only appeared to ask for permissions, becomes the protagonist.
In module 4, Data and Analytics, we will build the platform that answers those questions. We will start with 04-01, BigQuery, creating the alpinashop_analitica dataset in alpinashop-datos and loading the order history into it, to discover why an analytical warehouse is not just a bigger database, how you pay for what you query and why a badly written query can cost more than a whole instance. Then will come Pub/Sub to capture events at the moment they happen, Dataflow to transform them, and the rest of the pieces until we reach dashboards Lucía can use without having to ask anybody for anything.
Google Cloud Platform (GCP) Course
Module 1: Introduction to Google Cloud Platform
- What is Google Cloud Platform?
- Setting Up Your GCP Account
- A Tour of the GCP Console
- Projects, Resource Hierarchy and Billing
- Regions, Zones and the Shared Responsibility Model
- Cloud Shell and the gcloud CLI
Module 2: Core GCP Services
- Compute Engine: Virtual Machines on Google Cloud
- Cloud Storage: Object Storage
- Cloud SQL: Managed Relational Databases
- App Engine: Platform as a Service
- Google Kubernetes Engine (GKE)
- NoSQL Databases: Firestore, Bigtable and Spanner
- How to Choose the Right Compute Service
Module 3: Networking and Security
- VPC Networks
- Cloud Load Balancing
- Cloud CDN
- Identity and Access Management (IAM)
- Cloud Armor
- Secrets and Encryption: Secret Manager and Cloud KMS
- Cloud DNS, TLS Certificates and Publishing Services Securely
Module 4: Data and Analytics
- BigQuery: The Analytical Data Warehouse
- Cloud Dataflow: Batch and Streaming Data Processing
- Cloud Dataproc: Managed Spark and Hadoop
- Cloud Pub/Sub: Asynchronous Messaging
- Cloud Data Fusion: Code-Free Data Integration
- Orchestrating Pipelines with Cloud Composer and Workflows
- Data Governance and Dashboards with Dataplex and Looker Studio
Module 5: Machine Learning and AI
- Vertex AI: The Machine Learning Platform on GCP
- AutoML: Custom Models Without Writing Code
- TensorFlow on GCP: Training and Serving Models
- Natural Language API
- Vision API
- Generative AI on Vertex AI: Gemini Models and Embeddings
- MLOps: From Model to Product with Vertex AI Pipelines
Module 6: DevOps and Monitoring
- Cloud Build: Continuous Integration on GCP
- Cloud Source Repositories and Source Code Management
- Cloud Functions: Serverless Functions
- Cloud Monitoring (formerly Stackdriver): Metrics, Dashboards and Alerts
- Cloud Deployment Manager and Native Infrastructure as Code
- Cloud Logging and Cloud Trace: Logs, Traces and Diagnostics
- Terraform on GCP: Infrastructure as Code in Practice
Module 7: Advanced GCP Topics
- Hybrid and Multicloud with Anthos
- Serverless Computing with Cloud Run
- Advanced Networking: Shared VPC, Peering and Hybrid Connectivity
- Security Best Practices
- Cost Management and Optimization
- Reliability: SLOs, High Availability and Disaster Recovery
- Governance at Scale: Organization, Policies and Auditing
