Saltar al contenido principal

Database Setup

En las secciones anteriores vimos cómo Spring se comunica con las bases de datos y cómo generar entidades JPA. Ahora es momento de conectar una base de datos real. Vamos a reemplazar H2 (la base de datos embebida) por PostgreSQL 17 y usaremos Flyway para manejar las migraciones de esquema.

El plan

Esto es lo que vamos a configurar:

  • PostgreSQL como base de datos de producción
  • Flyway para ejecutar migraciones SQL versionadas en el inicio
  • pgAdmin como interfaz web para inspeccionar la base de datos
  • Un usuario de aplicación restringido (sakila_app) que solo tiene privilegios DML, no DDL

Acá está la clave de seguridad: los dos usuarios. El usuario admin (sakila) es el dueño del esquema y ejecuta las migraciones. La aplicación se conecta como sakila_app, que solo puede hacer SELECT, INSERT, UPDATE y DELETE. Si alguien compromete la conexión de tu app, un atacante no puede hacer DROP de tablas ni ALTER del esquema. Parece poco, pero en caso de una brecha de seguridad hace toda la diferencia.

Archivos involucrados

Archivos a Crear/Modificar
File Tree
.
├── ...
├── database/
│ ├── flyway/
│ │ ├── Dockerfile
│ │ └── migrations/
│ │ ├── V1__create_sakila_schema.sql
│ │ ├── V2__insert_sample_data.sql
│ │ └── V3__grant_app_user_privileges.sql
│ └── postgres/
│ ├── Dockerfile
│ └── init-users.sh
├── docker-compose.yml
└── spring-java/
├── ...
├── build.gradle
└── src
└── main
└── resources
├── ...
└── application.yaml

Migraciones con Flyway

Flyway ejecuta archivos SQL versionados en orden. Cada archivo se ejecuta exactamente una vez, y Flyway sigue el registro en una tabla llamada flyway_schema_history.

Tenemos tres migraciones:

  • V1: Create the Sakila Schema. Crea todas las tablas, primary keys, foreign keys e índices de la base de datos de ejemplo Sakila.

  • V2: Insert Sample Data. V2 inserta más de 47.000 líneas de datos de ejemplo. Es un archivo grande convertido del formato H2 al SQL compatible con PostgreSQL.

  • V3: Grant App User Privileges. Se ejecuta después de V1 y V2, así todas las tablas existen cuando otorga los privilegios.

    database/flyway/migrations/V3__grant_app_user_privileges.sql
    -- ============================================================================
    -- Flyway Migration V3: Grant DML Privileges to Application User
    -- ============================================================================
    --
    -- This migration enforces the principle of least privilege by granting
    -- the sakila_app user (created by postgres/init-users.sh) with DML-only
    -- privileges (SELECT, INSERT, UPDATE, DELETE) on all tables and sequences.
    --
    -- The sakila (admin) user retains full DDL privileges for future migrations.
    --

    -- Grant schema usage
    GRANT USAGE ON SCHEMA public TO sakila_app;

    -- Grant DML privileges on all existing tables
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sakila_app;

    -- Grant sequence privileges for auto-increment IDs (SERIAL columns)
    GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sakila_app;

    -- Set default privileges for future tables created by sakila (admin) user
    ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO sakila_app;

    -- Set default privileges for future sequences
    ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO sakila_app;

La separación importa. V3 se ejecuta como el usuario admin pero solo otorga derechos DML a sakila_app. Las líneas de ALTER DEFAULT PRIVILEGES se aseguran de que cualquier tabla creada por migraciones futuras también sea accesible al usuario de la app automáticamente.

Configuración Docker

En la práctica, los setups son diferentes

Es poco común encontrar la base de datos en el mismo archivo Docker Compose que los servicios de la aplicación. Las bases de datos generalmente se gestionan por separado, ya sea por deuda técnica que hace difícil containerizarlas, o por separación de responsabilidades (imaginate borrar accidentalmente el volumen donde viven tus datos de producción). Acá, todo convive junto por simplicidad.

Imagen PostgreSQL

Estamos extendiendo la imagen postgres:17-alpine para ejecutar un script de inicialización que crea el usuario sakila_app durante el primer inicio.

database/postgres/Dockerfile
FROM postgres:17-alpine
COPY init-users.sh /docker-entrypoint-initdb.d/01-init-users.sh
RUN chmod +x /docker-entrypoint-initdb.d/01-init-users.sh
database/postgres/init-users.sh
#!/bin/bash
set -e

echo "Creating sakila_app user with DML-only privileges..."

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Create restricted application user
CREATE USER sakila_app WITH PASSWORD '$SAKILA_APP_PASSWORD';

-- Grant connection to database
GRANT CONNECT ON DATABASE $POSTGRES_DB TO sakila_app;
EOSQL

echo "sakila_app user created successfully"
echo "Privileges will be granted by Flyway migration V3 after tables are created"

El script de inicialización se ejecuta mediante el mecanismo /docker-entrypoint-initdb.d/ de Docker, que ejecuta cualquier script en ese directorio cuando la base de datos se inicializa por primera vez. Crea sakila_app con una contraseña desde una variable de entorno y otorga derechos de conexión. Los privilegios a nivel de tabla vienen después mediante la migración V3 de Flyway.

Imagen Flyway

Flyway se ejecuta como un contenedor único: se conecta, aplica las migraciones pendientes y luego sale.

database/flyway/Dockerfile
FROM flyway/flyway:12-alpine
COPY database/flyway/migrations/ /flyway/sql/

Quemamos los archivos SQL en la imagen durante el build. Sin bind mounts ni acrobacias de volúmenes. El context: . en docker-compose.yml es necesario porque el Dockerfile copia desde database/flyway/migrations/ relativo a la raíz del repositorio.

¿Por qué un contenedor Flyway independiente?

Un contenedor independiente hace que la migración sea un paso previo que se completa antes de que la app inicie, usando las credenciales admin. La app nunca necesita saber la contraseña del admin. (Podrías ejecutar Flyway desde la app Spring Boot misma usando la

autoconfiguración spring-flyway

, pero separar responsabilidades mantiene las cosas más limpias.)

docker-compose.yml

docker-compose.yml
services:
postgres:
build:
context: database/postgres
dockerfile: Dockerfile
container_name: postgres
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- SAKILA_APP_PASSWORD=${SAKILA_APP_PASSWORD}
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
networks:
- monitoring

flyway:
build:
context: .
dockerfile: database/flyway/Dockerfile
container_name: flyway
command: migrate
restart: "no"
environment:
- FLYWAY_URL=jdbc:postgresql://postgres:5432/${POSTGRES_DB}
- FLYWAY_USER=${POSTGRES_USER}
- FLYWAY_PASSWORD=${POSTGRES_PASSWORD}
- FLYWAY_LOCATIONS=filesystem:/flyway/sql
depends_on:
postgres:
condition: service_healthy
networks:
- monitoring

pgadmin:
image: dpage/pgadmin4:9
container_name: pgadmin
environment:
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL}
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD}
ports:
- "5050:80"
volumes:
- pgadmin-data:/var/lib/pgadmin
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
networks:
- monitoring

spring-java:
# ...
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/${POSTGRES_DB}
- SPRING_DATASOURCE_USERNAME=sakila_app
- SPRING_DATASOURCE_PASSWORD=${SAKILA_APP_PASSWORD}
# ...
depends_on:
flyway:
condition: service_completed_successfully
tempo:
condition: service_started
# ...

spring-kotlin:
# ...
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/${POSTGRES_DB}
- SPRING_DATASOURCE_USERNAME=sakila_app
- SPRING_DATASOURCE_PASSWORD=${SAKILA_APP_PASSWORD}
# ...
flyway:
condition: service_completed_successfully
tempo:
condition: service_started
# ...

spring-groovy:
# ...
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/${POSTGRES_DB}
- SPRING_DATASOURCE_USERNAME=sakila_app
- SPRING_DATASOURCE_PASSWORD=${SAKILA_APP_PASSWORD}
# ...
depends_on:
flyway:
condition: service_completed_successfully
tempo:
condition: service_started
# ...
# ...
# ...
volumes:
postgres-data:
driver: local
pgadmin-data:
driver: local
# ...

Esto es lo que está pasando en este archivo compose:

  • postgres tiene un healthcheck. El depends_on de Flyway espera service_healthy, así que no intenta conectarse hasta que PostgreSQL esté realmente listo para aceptar conexiones.
  • flyway tiene restart: "no". Es un trabajo único. Una vez que termina, permanece terminado.
  • Los servicios Spring Boot dependen de flyway con condition: service_completed_successfully. No inician hasta que las migraciones estén completas.
  • Los volúmenes postgres-data y pgadmin-data persisten los datos entre reinicios.

Configuración de Spring Boot

Agregar el controlador PostgreSQL

Agrega runtimeOnly 'org.postgresql:postgresql' al archivo build de cada módulo. Es el controlador JDBC que Spring Boot necesita para conectarse a PostgreSQL.

build.gradle
// ...
dependencies {
// ...
runtimeOnly 'org.postgresql:postgresql'
}
// ...

application.yaml

Los parámetros de datasource y JPA apuntan a PostgreSQL. Las credenciales vienen de variables de entorno, que Docker Compose inyecta en tiempo de ejecución.

resources/application.yaml
# ...
datasource:
url: ${SPRING_DATASOURCE_URL}
driver-class-name: org.postgresql.Driver
username: ${SPRING_DATASOURCE_USERNAME}
password: ${SPRING_DATASOURCE_PASSWORD}
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: none
show-sql: false
flyway:
enabled: false
# ...

flyway.enabled: false le dice a Spring Boot que no ejecute Flyway por sí solo. Nuestro contenedor Flyway independiente se encarga de eso. Configurarlo en true aquí significaría que la app intenta ejecutar migraciones en cada inicio usando las credenciales sakila_app, que no tienen privilegios DDL. Fallaría.

ddl-auto: none significa que Hibernate no toca el esquema en absoluto. Flyway es el dueño de eso.

Desplegando el stack en Coolify

El docker-compose.yml está listo para desplegar en cualquier lugar donde Docker esté ejecutándose. Vamos a usar la misma instancia de Coolify que configuramos en la guía de deployment on a VPS.

Configurar variables de entorno

Antes de desplegar, agrega las nuevas variables a la configuración de entorno de tu aplicación en Coolify. Solo necesitan estar disponibles en tiempo de ejecución. No se necesitan otros controles.

VariableDescripción
POSTGRES_DBNombre de la base de datos (ej., sakila)
POSTGRES_USERUsuario admin (ej., sakila)
POSTGRES_PASSWORDContraseña del usuario admin
SAKILA_APP_PASSWORDContraseña del usuario restringido sakila_app
PGADMIN_DEFAULT_EMAILEmail de login de pgAdmin
PGADMIN_DEFAULT_PASSWORDContraseña de login de pgAdmin

Desplegar y asignar un dominio a pgAdmin

Dispara un despliegue. Una vez que esté ejecutándose, ve a la pestaña Configuration en Coolify y asigna un dominio al servicio pgadmin: https://sakila-pgadmin.your-domain.whatever:80. El proxy inverso de Coolify enrutará el tráfico al contenedor pgAdmin en el puerto 80.

Conectar pgAdmin a PostgreSQL

Una vez que pgAdmin esté ejecutándose, ábrelo en tu navegador e inicia sesión con el PGADMIN_DEFAULT_EMAIL y PGADMIN_DEFAULT_PASSWORD que configuraste. Luego registra el servidor PostgreSQL.

Haz clic derecho en ServersRegisterServer... y completa dos pestañas:

Pestaña General:

CampoValor
NameSakila Database (o lo que prefieras)

Pestaña Connection:

CampoValor
Host name/addresspostgres
Port5432
Maintenance databasesakila
Usernamesakila
Passwordtu valor de POSTGRES_PASSWORD
Save password?encendido

El hostname es postgres (el nombre del servicio Docker) porque pgAdmin y PostgreSQL comparten la misma red Docker. El DNS interno de Docker resuelve el nombre del servicio al contenedor correcto, así que no se necesita dirección IP.

Una vez conectado, tendrás acceso completo para explorar el esquema, ejecutar consultas e inspeccionar los datos Sakila:

Pgadmin F546e99ab16ae2c010f4b91f215a6858
Terminal
curl -s https://sakila-java.pollito.tech/api/films/1 | jq
{
"instance": "/api/films/1",
"status": 200,
"timestamp": "2026-03-13T16:19:38.475466237Z",
"trace": "625422f841d818b44d771d428cf802a4",
"data": {
"description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies",
"id": 1,
"language": "English",
"length": 86,
"rating": "PG",
"releaseYear": 2006,
"title": "ACADEMY DINOSAUR"
}
}