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

  1. What Marta stops doing: managed versus self-managed database
  2. Engines, editions and sizing
  3. Creating the alpinashop-pedidos instance
  4. Databases, users and passwords
  5. Regional high availability and failover
  6. Read replicas for Lucía's reports
  7. Backups, retention and point-in-time recovery
  8. Connectivity: public IP, private IP and the Auth Proxy
  9. Connecting the Flask application
  10. Migrating the current data
  11. Scheduled maintenance and windows
  12. Vertical scaling and limits
  13. When Cloud SQL falls short: AlloyDB and Spanner

  1. 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.

  1. 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.

  1. Creating the alpinashop-pedidos instance

gcloud 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=catalogo

This 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=REGIONAL enables 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=1000 logs 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)"

  1. 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-password

Always 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.

  1. 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-prod yes; for alpinashop-dev, no.

It can genuinely be tested, and it must be tested:

gcloud sql instances failover alpinashop-pedidos

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.

  1. 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=catalogo

Characteristics 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.

  1. 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=tienda

Managed 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.

  1. 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 needed

Cloud 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-pedidos

That project:region:instance identifier is the instance's connection name, and you will need it constantly:

gcloud sql instances describe alpinashop-pedidos \
  --format="value(connectionName)"

With the proxy running, any PostgreSQL client points at localhost as if the database were on your machine:

psql "host=127.0.0.1 port=5432 user=app_catalogo dbname=tienda"

And if you are in Cloud Shell, you do not even need to download it:

gcloud sql connect alpinashop-pedidos --user=postgres --database=tienda

  1. 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.

pip install "cloud-sql-python-connector[pg8000]" sqlalchemy flask
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=True is 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=1800 avoids connections expired by the idle timeouts of network intermediaries.
  • pool_size has to be multiplied by the number of processes. If gunicorn starts 4 workers with pool_size=5, that is 20 connections per instance. With 10 MIG instances at an autumn peak, 200 connections: exactly the max_connections we 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.

  1. 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.

gcloud storage cp /tmp/tienda.sql.gz gs://alpinashop-backups/migracion/

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=postgres

Step 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.

  1. 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:00

The 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.

  1. 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=100GB

Limits 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_connections depends on the instance's memory; each PostgreSQL connection is a process with its own memory.
  • There is no superuser. Cloud SQL's postgres user has broad privileges but is not SUPERUSER. 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.

  1. 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 the GRANT are 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 ANALYZE after 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_statement from 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

  1. Create alpinashop-pedidos-dev with PostgreSQL 16, db-g1-small or db-custom-1-3840, in europe-west1, with zonal (not regional) availability and daily backups with 7 days of retention. Justify why we do not put high availability in development.
  2. Create the tienda database and the users app_catalogo and informes_lectura.
  3. Connect with gcloud sql connect and create the tienda schema with a productos table (sku, nombre, precio, stock, activo).
  4. Grant app_catalogo read/write permissions and informes_lectura read-only, including the default privileges.
  5. Check from informes_lectura that an INSERT fails and a SELECT works.

Exercise 2: backups, PITR and recovery

  1. Insert three products and note the exact time.
  2. Simulate an accident: DELETE FROM tienda.productos; with no WHERE.
  3. Clone the instance to an instant before the deletion.
  4. Verify in the clone that the data is there and explain how you would return only that table to the original instance.
  5. 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

  1. Write a minimal Flask application that connects with the Python connector and exposes /productos.
  2. Configure the pool with pool_pre_ping, pool_recycle and a pool_size justified for 4 gunicorn workers and up to 6 instances.
  3. Calculate the total number of connections in the worst case and compare it with max_connections.
  4. Explain what would happen during a failover with and without pool_pre_ping.
  5. 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-password

In 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=tienda
SELECT count(*) FROM tienda.productos;            -- works
INSERT INTO tienda.productos(sku, nombre, precio)
VALUES ('X-1', 'test', 1.00);                      -- ERROR: permission denied

Solution 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
-- 2. The accident
DELETE FROM tienda.productos;
# 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=tienda

To 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=postgres

This 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.

# 5. Cleaning up
gcloud sql instances delete alpinashop-pedidos-rescate --quiet

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])
  1. The worst-case calculation:
(pool_size + max_overflow) x workers x instances
(5 + 2) x 4 x 6 = 168 connections

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.

  1. 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. With pool_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.

  2. 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

Module 2: Core GCP Services

Module 3: Networking and Security

Module 4: Data and Analytics

Module 5: Machine Learning and AI

Module 6: DevOps and Monitoring

Module 7: Advanced GCP Topics

Module 8: Final Project

© Copyright 2026. All rights reserved