Of everything AlpinaShop keeps on its physical server, the tienda database is the most valuable and the most fragile. It holds the orders, the customers, the stock and the prices. It runs on a single PostgreSQL that Marta installed years ago, with no replica, no failover, a nightly pg_dump nobody has ever tried to restore and a major version upgrade outstanding for two cycles because "it is not a good moment". If that disk fails on a Saturday in October, AlpinaShop does not sell.
Cloud SQL is Google Cloud's managed relational database service: PostgreSQL, MySQL and SQL Server with automatic backups, regional high availability, read replicas, managed patching and point-in-time recovery. In this lesson we will migrate the tienda database to the alpinashop-pedidos instance, connect the Flask application securely and leave Lucía a replica where she can run her reports without punishing the shop.
Contents
- What Marta stops doing: managed versus self-managed database
- Engines, editions and sizing
- Creating the
alpinashop-pedidosinstance - Databases, users and passwords
- Regional high availability and failover
- Read replicas for Lucía's reports
- Backups, retention and point-in-time recovery
- Connectivity: public IP, private IP and the Auth Proxy
- Connecting the Flask application
- Migrating the current data
- Scheduled maintenance and windows
- Vertical scaling and limits
- When Cloud SQL falls short: AlloyDB and Spanner
- What Marta stops doing: managed versus self-managed database
The most honest way to explain the value of Cloud SQL is to list the tasks and see who does them.
| Task | Marta's PostgreSQL today | Cloud SQL |
|---|---|---|
| Install and configure the engine | Marta, by hand | Google, in minutes |
| Apply engine security patches | Marta, when she finds a gap | Google, in the maintenance window |
| Patch the operating system | Marta | Google (there is no accessible OS) |
| Backups | Nightly pg_dump script |
Automatic, incremental, managed |
| Verify that the backups work | Nobody | Restore tested with one command |
| Recover to a specific instant | Impossible | PITR with WAL, to the second |
| Failover if the server goes down | Manual, hours | Automatic, on the order of a minute |
| Read replicas | Complex manual configuration | One command |
| Encryption at rest and in transit | Manual | On by default |
| Metrics and alerts | Whatever Marta has set up | Cloud Monitoring built in |
| Scale CPU or memory | Buy hardware | Change the tier and restart |
| Major version upgrade | A project lasting weeks | An assisted operation |
Fine tuning of postgresql.conf |
Full control | Only permitted flags |
Extensions and superuser |
Full control | A list of supported extensions, no real superuser |
The last two rows are the price to pay: you lose superuser access and full control of the engine. In Cloud SQL there is no SSH to the machine, you cannot install just any extension and you cannot touch every parameter. For the vast majority of applications — AlpinaShop included — it is an excellent trade. If your product depends on an exotic extension or a convoluted pg_hba.conf, you will have to check compatibility beforehand or stay on Compute Engine.
There is also a change in cost that is worth stating plainly: a VM with PostgreSQL installed is cheaper than an equivalent Cloud SQL instance. What you buy with that difference is Marta's time and the removal of a risk that is not covered today. If you put a price on an afternoon of downtime in the middle of the campaign, the sums work themselves out.
- Engines, editions and sizing
Available engines:
| Engine | Common versions | Notes |
|---|---|---|
| PostgreSQL | 13 to 17 | The most complete on Cloud SQL; popular extensions supported (pg_stat_statements, postgis, pgvector) |
| MySQL | 8.0, 8.4 | Very widely used; mature replicas and read groups |
| SQL Server | 2019, 2022 (Express to Enterprise) | Licence included in the hourly price; the most expensive |
AlpinaShop already uses PostgreSQL, so the choice is obvious: PostgreSQL 16, the same family as its current installation, with no changes to the application.
Editions. Cloud SQL comes in two editions that are worth distinguishing:
| Enterprise | Enterprise Plus | |
|---|---|---|
| Performance | Standard | Higher (data cache, more powerful machines) |
| Failover | On the order of 1 minute | Far lower (seconds) |
| Maintenance | With a brief restart | Almost without interruption |
| Backup retention | Up to 365 days | Longer, with more granularity |
| Cost | Lower | Noticeably higher |
For AlpinaShop's start, Enterprise is enough. If in future the shop cannot tolerate even a minute of interruption, Enterprise Plus is the escape route without changing service.
Sizing. You choose a machine type just as in Compute Engine. Practical criteria for a database:
- Memory comes first. The goal is for the "hot" dataset (indexes and frequently queried tables) to fit in RAM. A database that constantly reads from disk is slow no matter how much CPU it has.
- The disk determines the IOPS. As we saw in 02-01, on persistent disks performance grows with size. A 10 GB disk has very few IOPS even if your data only takes up 8 GB.
- Enable automatic storage growth. A database stopping because the disk is full is an avoidable incident.
- Start modest and scale. Changing the machine type is an operation of minutes with a restart.
AlpinaShop's tienda database takes up around 12 GB. We choose db-custom-2-7680 (2 vCPU, 7.5 GB of RAM) with a 50 GB SSD disk and automatic growth: plenty of room so that the disk does not limit the IOPS, and enough RAM to cache the active set.
- Creating the
alpinashop-pedidos instance
alpinashop-pedidos instancegcloud services enable sqladmin.googleapis.com
gcloud sql instances create alpinashop-pedidos \
--project=alpinashop-prod \
--database-version=POSTGRES_16 \
--edition=enterprise \
--tier=db-custom-2-7680 \
--region=europe-west1 \
--storage-type=SSD \
--storage-size=50GB \
--storage-auto-increase \
--availability-type=REGIONAL \
--backup-start-time=03:00 \
--retained-backups-count=14 \
--enable-point-in-time-recovery \
--retained-transaction-log-days=7 \
--maintenance-window-day=SUN \
--maintenance-window-hour=4 \
--maintenance-release-channel=production \
--database-flags=max_connections=200,log_min_duration_statement=1000 \
--labels=entorno=prod,equipo=plataforma,centro-coste=tienda,aplicacion=catalogoThis command concentrates nearly all the decisions in the lesson, so let us break it down:
--region=europe-west1(not a zone): Cloud SQL is a regional service. The primary instance lives in one zone, but the choice is expressed at region level.--availability-type=REGIONALenables high availability: a standby instance in another zone with synchronous replication. It is the flag that turns a single point of failure into a fault-tolerant architecture.--storage-auto-increase: the disk grows on its own when it fills up. It never shrinks, so keep watching its growth.--backup-start-time=03:00(UTC): a daily backup at a low-traffic hour.--enable-point-in-time-recovery+--retained-transaction-log-days=7: keeps the WAL so you can restore to any instant in the last 7 days.--maintenance-window-*: Google will apply updates on Sundays at 4:00 UTC, not on a Tuesday at 11:00.--maintenance-release-channel=production: you get the versions that have already matured, not the most recent ones.--database-flags: the engine's parameters are tuned here.log_min_duration_statement=1000logs every query taking more than a second, which is the best performance diagnosis tool there is and costs nothing to set up.
Creation takes a few minutes. When it finishes:
gcloud sql instances describe alpinashop-pedidos \
--format="table(name, state, databaseVersion, settings.tier, settings.availabilityType, ipAddresses[].ipAddress)"
- Databases, users and passwords
An instance is the server; the databases and users live inside it.
# Create the 'tienda' database
gcloud sql databases create tienda \
--instance=alpinashop-pedidos \
--charset=UTF8 \
--collation=es_ES.UTF8
# Set the password of the administrative user 'postgres'
gcloud sql users set-password postgres \
--instance=alpinashop-pedidos \
--prompt-for-password
# Application user, with limited permissions
gcloud sql users create app_catalogo \
--instance=alpinashop-pedidos \
--prompt-for-password
# Read-only user for Lucia's reports
gcloud sql users create informes_lectura \
--instance=alpinashop-pedidos \
--prompt-for-passwordAlways use --prompt-for-password: typing the password on the command line leaves it in the bash history and in the audit logs.
Creating the user does not give it permissions inside the database: that is done with SQL. Connect (section 8) and run:
-- A schema of the application's own, instead of using 'public'
CREATE SCHEMA IF NOT EXISTS tienda AUTHORIZATION app_catalogo;
-- The application: reading and writing data, no destructive DDL
GRANT USAGE ON SCHEMA tienda TO app_catalogo;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tienda TO app_catalogo;
ALTER DEFAULT PRIVILEGES IN SCHEMA tienda
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_catalogo;
-- Lucia: read only, and only on this schema
GRANT USAGE ON SCHEMA tienda TO informes_lectura;
GRANT SELECT ON ALL TABLES IN SCHEMA tienda TO informes_lectura;
ALTER DEFAULT PRIVILEGES IN SCHEMA tienda
GRANT SELECT ON TABLES TO informes_lectura;
-- Stop anyone creating objects in the public schema
REVOKE CREATE ON SCHEMA public FROM PUBLIC;The ALTER DEFAULT PRIVILEGES clause is the one that is usually missing: without it, the permissions apply only to the tables that existed at that moment, and any table created afterwards is inaccessible to the application. It is a classic source of errors after a deployment.
Cloud SQL also supports IAM authentication: instead of passwords, users and service accounts authenticate with their Google Cloud identity and a short-lived token. It is the recommended option because it removes passwords from the system. We note it here and develop it in 03-04.
- Regional high availability and failover
With --availability-type=REGIONAL, Cloud SQL keeps a standby instance in another zone of europe-west1, with synchronous replication of the disk: every write is confirmed in both zones before the commit is accepted.
graph LR
APP[Flask app<br/>europe-west1] -->|writes and reads| P[Primary<br/>europe-west1-b]
P -.->|synchronous replication| S[Standby<br/>europe-west1-c]
P -->|asynchronous replication| R[Read replica<br/>europe-west1-d]
R -->|read only| L[Lucia reports]
S -.->|automatic failover<br/>same IP and name| P
What this means in practice:
- The standby instance does not serve traffic. It is not a read replica: it exists only to take over. You pay for it and you do not use it, and that is precisely the insurance you are buying.
- Failover is automatic and keeps the connection address. The application does not change its configuration; it only sees a drop in connections of roughly a minute (less on Enterprise Plus).
- The application must know how to reconnect. This is the point people forget: if your connection pool does not retry, failover turns into an error visible to the customer. Configure retries in the pool (we will look at this in section 9).
- It roughly doubles the cost of the instance. It is the clearest economic decision in this lesson: for
alpinashop-prodyes; foralpinashop-dev, no.
It can genuinely be tested, and it must be tested:
Run this command in a test environment while the application is running and watch how long it takes to recover. A high availability plan that has never been exercised is a hypothesis, not a plan.
- Read replicas for Lucía's reports
Lucía runs aggregation queries over the orders: sales by category, monthly trends, products that are not moving. Executed against the primary database, they compete for CPU and memory with the shop, and a heavy report on a campaign Saturday can slow down the checkout.
The immediate solution is a read replica: an asynchronous copy that accepts SELECT queries.
gcloud sql instances create alpinashop-pedidos-replica-informes \
--master-instance-name=alpinashop-pedidos \
--region=europe-west1 \
--tier=db-custom-2-7680 \
--labels=entorno=prod,equipo=datos,centro-coste=analitica,aplicacion=catalogoCharacteristics you need to be clear about:
- Replication is asynchronous: the replica runs slightly behind (normally milliseconds or a few seconds). For reports that is irrelevant; for reading an order right after creating it, it will not do.
- It is read-only. Any write fails.
- It can have a different machine type from the primary: if Lucía's reports need more memory, you can give it to her without touching production.
- It can be in another region, which additionally serves as a geographic disaster recovery plan.
- It can be promoted to a standalone instance with
gcloud sql instances promote-replica. It is an irreversible operation that breaks replication, useful in a real recovery or to create a test environment with current data.
Keep an eye on the replication lag:
gcloud sql instances describe alpinashop-pedidos-replica-informes \
--format="value(replicaConfiguration)"In Cloud Monitoring, the relevant metric is database/replication/replica_lag (06-04). An alert when it exceeds a few minutes avoids reports that look right but are out of date.
Where the limit lies. A read replica solves today's problem, but analytical queries against a transactional database will always be inefficient: PostgreSQL stores by rows, and a report aggregating one column over millions of orders reads whole rows. The definitive solution for Lucía is to export the data to BigQuery, with columnar storage and an engine designed to aggregate. That is module 4. For now, the replica is the correct and cheap answer.
- Backups, retention and point-in-time recovery
We already configured daily backups with 14 days of retention and 7 days of PITR when creating the instance. Let us look at what each mechanism involves.
Automatic backups. Daily, incremental, stored outside the instance and with no appreciable performance cost. They are not pg_dump: they are copies of the storage.
# List available backups
gcloud sql backups list --instance=alpinashop-pedidos
# Manual backup before a risky operation
gcloud sql backups create --instance=alpinashop-pedidos \
--description="Before migrating the orders schema to v3"That last command is a habit worth its weight in gold: before any schema migration, a manual backup with an explicit description.
Restoring. There are two very different scenarios:
# 1. Restore a backup OVER the original instance (overwrites: destructive)
gcloud sql backups restore <BACKUP_ID> \
--restore-instance=alpinashop-pedidos
# 2. Restore to a NEW instance (the advisable route in an incident)
gcloud sql instances clone alpinashop-pedidos alpinashop-pedidos-restaurada \
--point-in-time="2026-08-05T09:15:00Z"Almost always you want the second. Restoring over the original instance destroys the current state, including the data written after the incident, which you may want to keep. Cloning to a new instance lets you compare, extract only what you need and decide calmly.
Point-in-time recovery (PITR). It is the difference between "I recover last night's backup" and "I recover the exact state at 09:14:59, one second before the script deleted the price table". It works by combining the last backup with the transaction logs (WAL). It requires PITR to be enabled before the incident; enabling it afterwards does not let you travel back in time.
A realistic example of use: at 09:15 Dani runs an UPDATE without a WHERE on productos. At 09:20 it is spotted. The correct sequence is to clone to alpinashop-pedidos-restaurada with --point-in-time at 09:14:59, check there that the prices are correct, export only the affected table and reimport it into production. The shop does not stop and not a single sale from those five minutes is lost.
Logical exports. As well as the managed backups, it is worth exporting periodically to Cloud Storage in SQL format: it is useful for migrating, for taking the data outside Google Cloud and as a copy independent of the service.
gcloud sql export sql alpinashop-pedidos \
gs://alpinashop-backups/tienda/tienda-$(date +%Y%m%d).sql.gz \
--database=tiendaManaged backups are tied to the instance: if someone deletes the instance, they are deleted with it (final backups aside). An export in a bucket with versioning and retention is a different layer of protection. This is, incidentally, why we devoted the previous lesson to Cloud Storage before this one.
- Connectivity: public IP, private IP and the Auth Proxy
Connecting to Cloud SQL is where most people get stuck, so let us take it step by step.
| Method | How it works | Security | When to use it |
|---|---|---|---|
| Public IP + authorised networks | The instance has a public IP; only connections from the IPs you authorise are accepted | Medium: it depends on a list of IPs, which in offices with a dynamic IP is a problem | Occasional administrative access |
| Private IP (VPC) | The instance gets an IP inside your private network; it is not reachable from the internet | High | Production, when the client is in the same VPC |
| Cloud SQL Auth Proxy | A local process opens an encrypted tunnel, authenticated with IAM, towards the instance | Very high: no exposed IP, with managed identity and encryption | The general recommendation, especially in development |
| Language connectors | A library that embeds the Auth Proxy inside the application | Very high | Production with Python, Java, Go or Node |
Authorised networks (public IP). Only for administrative access, and never 0.0.0.0/0:
gcloud sql instances patch alpinashop-pedidos \
--authorized-networks="88.20.13.45/32" \
--no-assign-ip # removes the public IP once it is no longer neededCloud SQL Auth Proxy. This is the key piece. A binary that runs alongside your application, listens on localhost and forwards connections to Cloud SQL over a TLS tunnel, authenticating with your Google Cloud credentials. Advantages: the instance needs no public IP, there are no certificates or IP lists to manage, and access is controlled with the IAM role roles/cloudsql.client.
# Download the proxy (version 2)
curl -o cloud-sql-proxy \
https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.11.0/cloud-sql-proxy.linux.amd64
chmod +x cloud-sql-proxy
# Start it: it listens on localhost:5432
./cloud-sql-proxy --port 5432 \
alpinashop-prod:europe-west1:alpinashop-pedidosThat project:region:instance identifier is the instance's connection name, and you will need it constantly:
With the proxy running, any PostgreSQL client points at localhost as if the database were on your machine:
And if you are in Cloud Shell, you do not even need to download it:
- Connecting the Flask application
In production you do not run a separate proxy process: you use the Python connector, which does the same thing inside the application's process.
import os
import sqlalchemy
from flask import Flask, jsonify
from google.cloud.sql.connector import Connector, IPTypes
app = Flask(__name__)
CONNECTION_NAME = os.environ["INSTANCIA_SQL"] # alpinashop-prod:europe-west1:alpinashop-pedidos
USERNAME = os.environ["DB_USER"] # app_catalogo
PASSWORD = os.environ["DB_PASS"] # injected from Secret Manager (03-06)
DATABASE = os.environ.get("DB_NAME", "tienda")
connector = Connector()
def _create_connection():
"""Opens a new connection through the Cloud SQL connector."""
return connector.connect(
CONNECTION_NAME,
"pg8000",
user=USERNAME,
password=PASSWORD,
db=DATABASE,
ip_type=IPTypes.PUBLIC, # IPTypes.PRIVATE if the instance only has a private IP
)
# The connection pool is created ONCE, when the application starts.
engine = sqlalchemy.create_engine(
"postgresql+pg8000://",
creator=_create_connection,
pool_size=5, # permanent connections per process
max_overflow=2, # extra connections during peaks
pool_timeout=30, # seconds waiting for a free connection
pool_recycle=1800, # recycles connections every 30 min
pool_pre_ping=True, # checks the connection before using it
)
@app.route("/productos")
def list_products():
query = sqlalchemy.text(
"""
SELECT sku, nombre, precio, stock
FROM tienda.productos
WHERE activo = true
ORDER BY nombre
LIMIT 50
"""
)
with engine.connect() as conn:
rows = conn.execute(query).mappings().all()
return jsonify([dict(r) for r in rows])
@app.route("/producto/<sku>")
def product_detail(sku):
query = sqlalchemy.text(
"SELECT sku, nombre, precio, stock FROM tienda.productos WHERE sku = :sku"
)
with engine.connect() as conn:
row = conn.execute(query, {"sku": sku}).mappings().first()
if row is None:
return {"error": "not found"}, 404
return dict(row)Aspects of the code you need to understand well:
pool_pre_ping=Trueis the flag that makes you survive a failover. Before handing over a connection from the pool, it checks that it is still alive; if the failover closed it, it discards it and opens another. Without this, after a failover the application returns errors until it is restarted.pool_recycle=1800avoids connections expired by the idle timeouts of network intermediaries.pool_sizehas to be multiplied by the number of processes. If gunicorn starts 4 workers withpool_size=5, that is 20 connections per instance. With 10 MIG instances at an autumn peak, 200 connections: exactly themax_connectionswe configured. This calculation is the number one cause of outages through connection exhaustion, and it has to be done beforehand, not afterwards.- Parameterised queries (
:sku). Never concatenate strings into SQL: that is the door to SQL injection. - The password comes from an environment variable, and in production that variable is filled in from Secret Manager (03-06), never from a file in the repository.
If the number of connections becomes a problem, the usual pattern is to put PgBouncer in front, or to use Cloud SQL Enterprise Plus with its built-in connection manager.
- Migrating the current data
The moment has come to move the real tienda database. For 12 GB, the simplest and most controlled route is pg_dump plus an import from Cloud Storage.
Step 1: dump on the source server.
# Plain SQL format, without owners or privileges (the users are different at the destination)
pg_dump \
--host=localhost \
--username=postgres \
--dbname=tienda \
--no-owner \
--no-acl \
--format=plain \
--file=/tmp/tienda.sql
gzip /tmp/tienda.sql--no-owner and --no-acl stop the dump trying to assign owners and permissions to users that do not exist in Cloud SQL. It is the most frequent cause of errors during an import.
Step 2: upload the dump to the bucket.
Step 3: authorise Cloud SQL to read the bucket. The instance has a service account of its own, and it needs explicit permission:
SA=$(gcloud sql instances describe alpinashop-pedidos \
--format="value(serviceAccountEmailAddress)")
gcloud storage buckets add-iam-policy-binding gs://alpinashop-backups \
--member="serviceAccount:$SA" \
--role="roles/storage.objectViewer"This step is always forgotten and produces an unhelpful permissions error. Remember it: the Cloud SQL instance is just another identity, and without permission it does not read your bucket.
Step 4: import.
gcloud sql import sql alpinashop-pedidos \
gs://alpinashop-backups/migracion/tienda.sql.gz \
--database=tienda \
--user=postgresStep 5: verify. Never take a migration on trust without cross-checking:
-- Number of rows per table
SELECT relname AS table_name, n_live_tup AS approx_rows
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
-- Business checks: totals that must match the source
SELECT count(*) AS orders, sum(total) AS total_amount FROM tienda.pedidos;
SELECT count(*) AS products FROM tienda.productos;
-- Update the planner statistics after a bulk load
ANALYZE;That final ANALYZE matters: after a bulk import the planner's statistics are empty and queries can be absurdly slow until they are recalculated.
The interruption problem. This procedure means stopping writes while dumping and importing: for 12 GB, perhaps 30-40 minutes with the shop in read-only mode. That is acceptable if it is done in the small hours.
When it is not acceptable, the tool is Database Migration Service (DMS): it creates a continuous replica from your source PostgreSQL into Cloud SQL, keeps it synchronised while the shop carries on working and allows a final cut-over of seconds. It requires the source to have logical replication enabled and connectivity with Google Cloud. For a migration of tens or hundreds of GB with an availability requirement, it is the right option; for AlpinaShop's 12 GB in the small hours of a Sunday, pg_dump is simpler and sufficient.
- Scheduled maintenance and windows
Google updates the engine and the infrastructure periodically. Those updates involve a brief restart, and you want to decide when it happens.
gcloud sql instances patch alpinashop-pedidos \
--maintenance-window-day=SUN \
--maintenance-window-hour=4 \
--maintenance-release-channel=production \
--deny-maintenance-period-start-date=2026-10-01 \
--deny-maintenance-period-end-date=2026-11-15 \
--deny-maintenance-period-time=00:00:00The last three flags are especially valuable for AlpinaShop: they define a maintenance deny period that blocks updates during the autumn campaign. Google will postpone them until after 15 November.
| Channel | What it receives | Recommendation |
|---|---|---|
preview |
New versions sooner | Test environments only |
production |
Versions that have already stabilised | Production |
Good practice: keep alpinashop-dev on preview with the window one day earlier than production. That way changes reach development first and you have room to react.
- Vertical scaling and limits
Cloud SQL scales vertically for writes: there is no way to spread writes across several instances.
# Change the machine type (implies a restart, a few minutes of interruption)
gcloud sql instances patch alpinashop-pedidos --tier=db-custom-4-15360
# Grow the disk (live, without interruption; it can never be reduced)
gcloud sql instances patch alpinashop-pedidos --storage-size=100GBLimits and considerations you need to know:
- The disk grows but does not shrink. If you enable automatic growth and an anomalous workload inflates the disk to 2 TB, you will keep paying for 2 TB. The only way out is to export and recreate.
- Connections are a scarce resource.
max_connectionsdepends on the instance's memory; each PostgreSQL connection is a process with its own memory. - There is no superuser. Cloud SQL's
postgresuser has broad privileges but is notSUPERUSER. Some extensions and operations are not available. - Extensions are limited to the supported list. Check yours before migrating.
- Scaling has a ceiling. The largest machine type available marks the physical limit of your database. If you get close to it, that is a sign you need a different architecture.
- When Cloud SQL falls short: AlloyDB and Spanner
Three signs that Cloud SQL has become too small for you: writes saturate the largest instance available; you need active writes in several regions simultaneously; or analytical queries against the same database are unavoidable and very heavy.
| Service | What it is | When it justifies the change |
|---|---|---|
| Cloud SQL | Managed PostgreSQL/MySQL/SQL Server, one primary instance | The general case. Up to a few TB and one region |
| AlloyDB | PostgreSQL compatible, rearchitected by Google: distributed storage, far more transactional performance and a built-in columnar engine for analytics | You need far more performance without leaving the PostgreSQL ecosystem, or to mix transactional and analytical workloads |
| Cloud Spanner | A globally distributed relational database, with strongly consistent transactions and horizontal scaling of writes | Global scale, writes in several regions, extreme availability. A much higher base cost |
For AlpinaShop, Cloud SQL comfortably covers the foreseeable horizon: a Spanish shop with seasonal peaks is a long way from those limits. The natural growth path would be, first, to offload the analytics to BigQuery (module 4); then, if transactional volume demanded it, AlloyDB, which allows migration with minimal changes thanks to its PostgreSQL compatibility. Spanner is studied in detail in lesson 02-06, within the landscape of non-relational and distributed databases.
Common Mistakes and Tips
- Creating the instance without high availability, "we will enable it later". Switching to REGIONAL afterwards is possible, but it means a restart and tends to be postponed indefinitely.
- Trusting backups that have never been restored. Schedule a quarterly test restore to a cloned instance.
- Enabling PITR after the incident. It is no use: you have to have it beforehand.
- Restoring over the original instance during an incident. Clone to a new instance and decide calmly.
- Forgetting
ALTER DEFAULT PRIVILEGES. Tables created after theGRANTare left inaccessible. - Not granting read access on the bucket to the instance's service account before importing.
- Importing without
--no-owner --no-acl. It fails because of users that do not exist at the destination. - Not running
ANALYZEafter the import. Absurdly slow queries because of empty statistics. - Sizing the pool without multiplying by processes and instances. Connection exhaustion at the worst possible moment.
- Omitting
pool_pre_ping. The application does not survive a failover. - Exposing the public IP with broad authorised networks. Use the Auth Proxy or the connector.
- Tip: enable
log_min_duration_statementfrom day one. It is free diagnostics. - Tip: take a manual backup with a description before every schema migration.
- Tip: define a maintenance deny period covering the autumn campaign.
- Tip: export periodically to a bucket with versioning, as a copy independent of the instance's lifecycle.
Exercises
Exercise 1: creating and securing the development instance
- Create
alpinashop-pedidos-devwith PostgreSQL 16,db-g1-smallordb-custom-1-3840, ineurope-west1, with zonal (not regional) availability and daily backups with 7 days of retention. Justify why we do not put high availability in development. - Create the
tiendadatabase and the usersapp_catalogoandinformes_lectura. - Connect with
gcloud sql connectand create thetiendaschema with aproductostable (sku, nombre, precio, stock, activo). - Grant
app_catalogoread/write permissions andinformes_lecturaread-only, including the default privileges. - Check from
informes_lecturathat anINSERTfails and aSELECTworks.
Exercise 2: backups, PITR and recovery
- Insert three products and note the exact time.
- Simulate an accident:
DELETE FROM tienda.productos;with noWHERE. - Clone the instance to an instant before the deletion.
- Verify in the clone that the data is there and explain how you would return only that table to the original instance.
- Delete the clone and work out what it would have cost you to keep it running for a month.
Exercise 3: connecting from Flask with fault tolerance
- Write a minimal Flask application that connects with the Python connector and exposes
/productos. - Configure the pool with
pool_pre_ping,pool_recycleand apool_sizejustified for 4 gunicorn workers and up to 6 instances. - Calculate the total number of connections in the worst case and compare it with
max_connections. - Explain what would happen during a failover with and without
pool_pre_ping. - State where the password should come from in production and why not from a variable in the code.
Solutions
Solution 1
gcloud sql instances create alpinashop-pedidos-dev \
--database-version=POSTGRES_16 \
--tier=db-custom-1-3840 \
--region=europe-west1 \
--availability-type=ZONAL \
--storage-type=SSD --storage-size=20GB --storage-auto-increase \
--backup-start-time=02:00 --retained-backups-count=7 \
--labels=entorno=dev,equipo=plataforma,centro-coste=tienda,aplicacion=catalogo
gcloud sql databases create tienda --instance=alpinashop-pedidos-dev
gcloud sql users create app_catalogo --instance=alpinashop-pedidos-dev --prompt-for-password
gcloud sql users create informes_lectura --instance=alpinashop-pedidos-dev --prompt-for-passwordIn development we do not put high availability because it doubles the cost to protect us against a risk that has no consequences in development: if the instance goes down for a few hours, nobody loses a sale. High availability is paid for where there is revenue at stake. It is the same logic of allocating resources that we applied with the centro-coste labels in 01-04.
-- 3 and 4
CREATE SCHEMA IF NOT EXISTS tienda;
CREATE TABLE tienda.productos (
sku TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
precio NUMERIC(10,2) NOT NULL CHECK (precio >= 0),
stock INTEGER NOT NULL DEFAULT 0,
activo BOOLEAN NOT NULL DEFAULT true
);
GRANT USAGE ON SCHEMA tienda TO app_catalogo, informes_lectura;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tienda TO app_catalogo;
GRANT SELECT ON ALL TABLES IN SCHEMA tienda TO informes_lectura;
ALTER DEFAULT PRIVILEGES IN SCHEMA tienda
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_catalogo;
ALTER DEFAULT PRIVILEGES IN SCHEMA tienda
GRANT SELECT ON TABLES TO informes_lectura;# 5. Check with the read-only user
gcloud sql connect alpinashop-pedidos-dev --user=informes_lectura --database=tiendaSELECT count(*) FROM tienda.productos; -- works
INSERT INTO tienda.productos(sku, nombre, precio)
VALUES ('X-1', 'test', 1.00); -- ERROR: permission deniedSolution 2
-- 1
INSERT INTO tienda.productos (sku, nombre, precio, stock) VALUES
('MOC-40', 'Trekking Backpack 40L', 89.90, 25),
('BOT-GTX', 'Alpina Gore-Tex Boots', 149.00, 12),
('TDA-2P', 'Ultralight 2-person Tent', 219.50, 8);
SELECT now(); -- note down this timestamp# 3. Clone to an earlier instant (requires PITR enabled on the instance)
gcloud sql instances clone alpinashop-pedidos-dev alpinashop-pedidos-rescate \
--point-in-time="2026-08-05T09:14:59Z"
# 4. Verify in the clone
gcloud sql connect alpinashop-pedidos-rescate --user=postgres --database=tiendaTo return only that table to the original instance, you export it from the clone and import it into production, without touching the rest:
gcloud sql export sql alpinashop-pedidos-rescate \
gs://alpinashop-backups/rescate/productos.sql.gz \
--database=tienda --table=tienda.productos
gcloud sql import sql alpinashop-pedidos-dev \
gs://alpinashop-backups/rescate/productos.sql.gz \
--database=tienda --user=postgresThis is the advantage of cloning instead of restoring on top: you recover exactly the damaged table and keep all the correct data written after the incident.
A clone is a complete instance and is billed as such from the moment it exists: a db-custom-1-3840 with 20 GB of SSD comes to tens of dollars a month. Leaving forgotten rescue clones running is a very common silent expense; delete them as soon as the recovery is over.
Solution 3
import os
import sqlalchemy
from flask import Flask, jsonify
from google.cloud.sql.connector import Connector
app = Flask(__name__)
connector = Connector()
def _connect():
return connector.connect(
os.environ["INSTANCIA_SQL"],
"pg8000",
user=os.environ["DB_USER"],
password=os.environ["DB_PASS"],
db="tienda",
)
engine = sqlalchemy.create_engine(
"postgresql+pg8000://",
creator=_connect,
pool_size=5,
max_overflow=2,
pool_recycle=1800,
pool_pre_ping=True,
)
@app.route("/productos")
def products():
with engine.connect() as conn:
rows = conn.execute(
sqlalchemy.text("SELECT sku, nombre, precio FROM tienda.productos")
).mappings().all()
return jsonify([dict(r) for r in rows])- The worst-case calculation:
With max_connections=200 there is room, but it is tight: 32 connections are left for maintenance tasks, Lucía's reports and administrative sessions. If the MIG could reach 10 instances, that would be 280 connections and the database would reject them. The options are to lower pool_size to 3, increase the instance's memory to raise max_connections, or introduce PgBouncer.
-
During a failover, the pool's open connections are left broken. Without
pool_pre_ping, SQLAlchemy hands out those dead connections and every request fails with a connection error until the pool recycles or the application restarts: minutes of 500 errors visible to the customer. Withpool_pre_ping, every connection is checked before use; broken ones are discarded and new ones opened transparently, and the user only perceives slightly higher latency for a few seconds. -
In production the password must come from Secret Manager (lesson 03-06), injected as an environment variable or read at start-up with the application's service account. Never in the code or in the repository, because it would stay in the Git history forever, would be visible to anyone with access to the repository and could not be rotated without redeploying. The optimal option is Cloud SQL IAM authentication outright, which removes the password.
Conclusion
The tienda database has stopped being AlpinaShop's weak point. You have seen in a concrete table which tasks Marta stops doing — patches, backups, failover, replicas, encryption — and what is given up in exchange: the superuser and full control of the engine, a trade that for this application is clearly favourable. You have chosen PostgreSQL 16, the Enterprise edition and a reasoned sizing in which memory and disk size matter more than CPU. You have created alpinashop-pedidos in europe-west1 with a single command that concentrates the important decisions: regional high availability, daily backups with 14 days of retention, 7 days of PITR, a maintenance window early on Sunday and slow query logging from the very first minute.
You have created the tienda database with differentiated users and minimal permissions, without forgetting ALTER DEFAULT PRIVILEGES. You have understood that the high availability standby instance does not serve traffic and that its value lies in the automatic failover that keeps the connection address, and that this failover is only transparent if the application reconnects — hence pool_pre_ping. You have created a read replica so that Lucía's reports do not compete with the shop, knowing that it is a correct but temporary solution, because real analytics lives in BigQuery. You have configured backups, you have learned to clone to a point in time instead of restoring destructively, and you have seen why an export to a bucket with versioning is a different layer of protection from the managed backups. You have compared public IP, private IP, Auth Proxy and the Python connector, and you have connected the Flask catalogue with a well-sized pool and parameterised queries. And you have carried out the real migration with pg_dump, an import from the bucket, verification and ANALYZE, with Database Migration Service noted down for when the interruption is not acceptable.
AlpinaShop now has its compute on virtual machines, its images in a bucket and its data in a managed database. But Marta is still maintaining operating systems, instance templates and startup scripts. In 02-04, App Engine, we will see what happens when that layer is removed too: what a PaaS is, how the standard and flexible environments differ, how a whole application is described in a twenty-line app.yaml, how versions are deployed and traffic split to do a canary, how scaling is configured — including scaling to zero — and how the same Flask application reaches Cloud SQL and Cloud Storage without anyone administering a single server.
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
