Skip to main content

Flyway and Spring Boot: Building a Reliable Database Migration Strategy

 


Modern Spring Boot applications rarely remain simple for long. A project may begin with a few entities and a handful of tables, but as it evolves, database schemas change continuously: columns are added, indexes are introduced, constraints change, new tables appear, and sometimes data itself needs to be transformed.

The challenge isn't creating these changes. The challenge is making sure the same changes are applied consistently across every environment.

This is where Flyway comes in.

Flyway provides a versioned, repeatable mechanism for evolving a relational database schema alongside application code. When integrated with Spring Boot, it allows database changes to become part of the application's lifecycle and, eventually, the CI/CD deployment pipeline.

In this article, we'll explore Flyway from the ground up and build a Spring Boot + PostgreSQL setup using migrations.


1. The Problem: Database Schema Changes

Imagine a Spring Boot application initially has:

users
projects

The application reaches production.

A few weeks later, we need to add:

ALTER TABLE users
ADD COLUMN email VARCHAR(255);

Then another developer adds:

CREATE INDEX idx_users_email
ON users(email);

And another release introduces:

CREATE TABLE project_members (...);

Now we have multiple environments:

Developer A
    Database → Version 5

Developer B
    Database → Version 4

QA
    Database → Version 3

Production
    Database → Version 2

Someone has to know:

  • Which SQL scripts have already run?

  • Which scripts still need to run?

  • In what order?

  • Has production received the same schema changes as QA?

  • Can a new developer reproduce the database from scratch?

  • Can CI/CD safely upgrade an existing database?

Without a migration mechanism, database changes often become a collection of manually executed SQL scripts and deployment instructions.

That doesn't scale well.


2. What Is Flyway?

Flyway is a database migration and versioning tool.

Its fundamental idea is simple:

Treat database schema changes as version-controlled artifacts.

Instead of manually changing a database, we create migration files:

V1__initial_schema.sql
V2__create_users.sql
V3__create_projects.sql
V4__add_email_to_users.sql
V5__create_project_members.sql

Flyway executes them in order.

Conceptually:

             Git Repository
                   │
                   ▼
          Database Migrations
                   │
       ┌───────────┼───────────┐
       ▼           ▼           ▼
      V1          V2          V3
       │           │           │
       └───────────┴───────────┘
                   │
                   ▼
              PostgreSQL

The important part is that the migration files live alongside your application code.

Therefore:

Application version
        +
Database schema version

can evolve together.


3. Flyway's Migration Philosophy

Flyway follows a straightforward principle:

Don't manually modify a managed database schema. Create a migration instead.

For example, suppose V2 created:

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(100) NOT NULL
);

Later, we need an email address.

Don't edit V2.

Instead, create V3:

ALTER TABLE users
ADD COLUMN email VARCHAR(255);

So the history becomes:

V1
 │
 ▼
Initial database
 │
 ▼
V2
 │
 ▼
users table
 │
 ▼
V3
 │
 ▼
users.email

This historical record is extremely valuable.


4. Flyway Migration Naming

Flyway's conventional versioned migration naming pattern is:

V<version>__<description>.sql

For example:

V1__initial_schema.sql
V2__create_users.sql
V3__create_projects.sql
V4__add_email_to_users.sql

Notice the double underscore:

V1__initial_schema.sql
   ^^

The first part is the migration version.

The second part is the human-readable description.


5. How Flyway Knows What Has Already Run

This is one of Flyway's most important features.

Flyway creates a metadata table in the database:

flyway_schema_history

It records migrations that have been executed.

Conceptually, it looks like:

VersionDescriptionSuccess
1initial schematrue
2create userstrue
3create projectstrue
4add emailtrue

When the application starts, Flyway compares:

Migration files in application
              ↓
flyway_schema_history in database

For example:

Application:

V1
V2
V3
V4
V5

Database:

V1
V2
V3
V4

Flyway recognizes:

V1 → already applied
V2 → already applied
V3 → already applied
V4 → already applied
V5 → pending

It executes V5.


6. Flyway and Spring Boot

Spring Boot makes Flyway integration relatively straightforward.

A typical Gradle setup includes:

implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")

The first dependency integrates Flyway with Spring Boot.

The second provides PostgreSQL-specific Flyway support.

This distinction is particularly important with modern Flyway versions, where database support is modularized.

For PostgreSQL:

Spring Boot
     │
     ▼
spring-boot-starter-flyway
     │
     ├── Flyway Core
     │
     └── PostgreSQL database module
             │
             ▼
         PostgreSQL

7. Spring Boot Configuration

A typical application.yml might contain:

spring:
  application:
    name: astra

  datasource:
    url: jdbc:postgresql://localhost:5432/astra
    username: astra
    password: astra_local

  jpa:
    hibernate:
      ddl-auto: validate

The important configuration here is:

spring:
  jpa:
    hibernate:
      ddl-auto: validate

This creates an important separation of responsibilities.

Flyway

Owns:

Database schema evolution

Hibernate

Owns:

Object-relational mapping

Hibernate should not be responsible for silently modifying the production schema.


8. Why ddl-auto=update Isn't My Preferred Production Strategy

You may see Spring Boot examples using:

spring:
  jpa:
    hibernate:
      ddl-auto: update

This can be convenient during experimentation.

Hibernate can attempt to modify the database based on changes to entities.

For example:

@Entity
public class User {
    private Long id;
    private String username;
    private String email;
}

Hibernate may infer that the database needs an additional column.

That sounds convenient, but it becomes problematic when database changes need to be:

  • reviewed

  • audited

  • tested

  • rolled through environments

  • coordinated with application releases

  • reproduced reliably

For a production-oriented architecture, I prefer:

ddl-auto: validate

and:

Flyway → schema changes
Hibernate → entity mapping

9. The Flyway + Hibernate Responsibility Boundary

This distinction is worth remembering.

Flyway asks:

"What does the database schema look like at version N?"

Hibernate asks:

"How does my Java domain model map onto that schema?"

For example:

                 PostgreSQL
                     ▲
                     │
             ┌───────┴───────┐
             │               │
          Flyway          Hibernate
             │               │
             │               │
       Schema evolution   ORM mapping
             │               │
             └───────┬───────┘
                     │
                 Spring Boot

This separation makes the architecture much easier to reason about.


10. Creating the First Migration

The conventional location in a Spring Boot project is:

src/
└── main/
    └── resources/
        └── db/
            └── migration/

So:

src/main/resources/db/migration/

A migration could be:

V1__initial_schema.sql

For example:

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

When the application starts, Flyway discovers the migration.


11. What Happens During Application Startup?

A simplified startup sequence looks like:

Spring Boot starts
       │
       ▼
Create DataSource
       │
       ▼
Connect to PostgreSQL
       │
       ▼
Initialize Flyway
       │
       ▼
Check flyway_schema_history
       │
       ▼
Find pending migrations
       │
       ▼
Execute migrations
       │
       ▼
Initialize JPA/Hibernate
       │
       ▼
Application ready

This ordering is important.

We want the database schema to be ready before Hibernate begins relying on it.


12. What Happens When a Migration Fails?

Suppose:

V1 ✅
V2 ✅
V3 ❌

Flyway records the migration state, and the application startup fails rather than silently continuing with an unexpected schema.

That's generally desirable.

You don't want:

Application:
"Database migration failed, but let's start anyway."

You want:

Migration failed
       ↓
Application startup fails
       ↓
Deployment fails
       ↓
Problem gets noticed

This is an important fail-fast principle.


13. Flyway in CI/CD

This is where Flyway becomes particularly powerful.

Imagine Astra has:

Developer
    ↓
Git
    ↓
CI
    ↓
Tests
    ↓
Build
    ↓
Migration
    ↓
Deployment

A deployment pipeline might conceptually look like:

                 Git
                  │
                  ▼
             Build Astra
                  │
                  ▼
              Run Tests
                  │
                  ▼
            Build Artifact
                  │
                  ▼
          Run Flyway Migration
                  │
                  ▼
         Deploy Application

Now the database isn't an independent manual task.

It becomes part of the software delivery lifecycle.


14. Flyway Across Multiple Environments

Suppose Astra has:

Development
QA
UAT
Production

Each database may be at a different migration version.

For example:

Development → V10
QA          → V9
UAT         → V8
Production  → V7

When V10 is deployed to production, Flyway can apply:

V8
V9
V10

in order.

This gives us an important property:

Database environments can converge toward the same schema version through controlled migrations.


15. Database Migrations Should Be Immutable

Once a migration has been applied to a shared environment, don't casually edit it.

Suppose:

V3__create_projects.sql

has already been executed.

Don't modify V3 to add a new column.

Instead:

V4__add_project_status.sql

This preserves the history:

V1
V2
V3
V4

rather than rewriting history.

This is very similar to the principle of immutable commits in source control.


16. Example: Evolving Astra

Let's imagine Astra eventually has:

projects

V1

CREATE TABLE projects (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

V2

We introduce a status:

ALTER TABLE projects
ADD COLUMN status VARCHAR(30) NOT NULL DEFAULT 'ACTIVE';

V3

We add an index:

CREATE INDEX idx_projects_status
ON projects(status);

The history becomes:

V1 → projects
V2 → project status
V3 → project status index

A new developer can reconstruct the database by running all migrations from V1 onward.


17. Schema Changes vs Data Migrations

Not every migration is simply:

CREATE TABLE

Sometimes data needs to be transformed.

For example:

Old:
full_name

New:
first_name
last_name

A migration might need to:

  1. Add new columns.

  2. Transform existing data.

  3. Validate the transformation.

  4. Eventually remove the old column.

That migration might involve:

ALTER TABLE users
ADD COLUMN first_name VARCHAR(100);

ALTER TABLE users
ADD COLUMN last_name VARCHAR(100);

UPDATE users
SET ...

These migrations require significantly more care because they operate on existing production data, not merely schema metadata.


18. Backward-Compatible Database Changes

One of the most important production considerations is that application deployment and database migration don't always happen instantaneously.

Suppose version 1 of the application expects:

users.name

and version 2 expects:

users.first_name
users.last_name

Immediately renaming the column could break the currently running version.

A safer deployment strategy may be:

Phase 1
Add new columns

       ↓

Phase 2
Application starts writing both representations

       ↓

Phase 3
Backfill existing data

       ↓

Phase 4
Application reads new columns

       ↓

Phase 5
Remove old column

This is often called an expand-and-contract approach.

Flyway provides the mechanism for versioning those database changes; the deployment strategy determines how safely the application and database evolve together.


19. Flyway and Docker

Flyway works particularly well with containerized applications.

For local Astra development:

Docker
 │
 └── PostgreSQL

while:

Astra Spring Boot
 │
 └── Flyway
       │
       └── PostgreSQL

Your local database can therefore be recreated while your migration history remains in Git.

That is extremely useful for onboarding:

git clone Astra
       ↓
docker compose up
       ↓
./gradlew bootRun
       ↓
Flyway
       ↓
Database automatically reaches required schema

A new developer doesn't need a 20-page database setup document.


20. Flyway and Azure

This becomes even more interesting for an Azure-based architecture.

Astra could eventually look like:

                    Azure
                      │
               ┌──────┴──────┐
               │             │
          Astra API      PostgreSQL
               │             │
               └──────┬──────┘
                      │
                   Flyway

The local environment might use:

Docker PostgreSQL

while Azure uses:

Azure Database for PostgreSQL

The migration files remain the same:

V1
V2
V3
...

Only the database connection changes.

That is one of the major benefits of treating migrations as application artifacts rather than manually maintained infrastructure instructions.


21. Secrets Shouldn't Live in application.yml

For local experimentation, you might have:

spring:
  datasource:
    username: astra
    password: astra_local

But production credentials should not be committed into Git.

A more appropriate approach is environment-driven configuration:

spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

Then production can provide those values through an appropriate secret-management mechanism.

In Azure, this can eventually be integrated with services such as Azure Key Vault.

The important principle is:

Configuration ≠ Secret

22. A Practical Project Structure

A mature Spring Boot application might look like:

backend/
├── build.gradle.kts
├── gradlew
├── gradlew.bat
│
└── src/
    ├── main/
    │   ├── java/
    │   │   └── com/rishiraj/astra/
    │   │
    │   └── resources/
    │       ├── application.yml
    │       │
    │       └── db/
    │           └── migration/
    │               ├── V1__initial_schema.sql
    │               ├── V2__create_users.sql
    │               └── V3__create_projects.sql
    │
    └── test/

The important relationship is:

Java code
    +
Migration scripts
    +
Application configuration
    +
Tests
    ↓
Version controlled together

23. Common Mistakes

Mistake 1 — Editing old migrations

Avoid:

V2__create_users.sql

being changed after V2 has been applied elsewhere.

Create:

V3__modify_users.sql

instead.


Mistake 2 — Letting Hibernate own production schema changes

Avoid relying on:

ddl-auto: update

as the production migration mechanism.

Use Flyway for explicit schema evolution.


Mistake 3 — Manually changing production

Avoid:

SSH into production
       ↓
psql
       ↓
ALTER TABLE...

unless there's an exceptional operational reason.

If it's a planned schema change, preferably make it a migration.


Mistake 4 — Putting credentials in Git

Avoid:

password: production-secret

in a committed configuration file.

Use secret management.


Mistake 5 — Making huge migrations

Prefer small, understandable changes:

V5 → Add project status
V6 → Add status index
V7 → Add project owner

rather than one enormous migration containing unrelated changes.


24. Flyway vs Hibernate

A useful comparison:

ResponsibilityFlywayHibernate
Create schema✅Possible, but not preferred for production
Version schema✅❌
Track migrations✅❌
Execute SQL migrations✅❌
ORM❌✅
Entity mapping❌✅
JPQL❌✅
Schema validation❌✅
Production schema lifecycle✅Usually not its role

The two tools complement each other.

They aren't competitors.


25. The Mental Model I Recommend

For a production-oriented Spring Boot system, think:

                    APPLICATION
                         │
               ┌─────────┴─────────┐
               │                   │
           Hibernate            Flyway
               │                   │
               │                   │
        Object Mapping       Schema Evolution
               │                   │
               └─────────┬─────────┘
                         │
                         ▼
                     PostgreSQL

Hibernate knows:

What is a Project?

Flyway knows:

How did the database become capable of storing a Project?

That distinction is subtle but extremely important.


26. Where Astra Stands Now

For our Astra backend, we've established:

Java 25
   ↓
Spring Boot 4.1
   ↓
Gradle
   ↓
Spring Data JPA
   ↓
Hibernate
   ↓
HikariCP
   ↓
PostgreSQL 17

And we've now added:

Flyway
   ↓
Versioned database migrations

So the architecture is becoming:

                    Astra
                      │
          ┌───────────┴───────────┐
          │                       │
       Hibernate                Flyway
          │                       │
    Entity Mapping          Schema Evolution
          │                       │
          └───────────┬───────────┘
                      ▼
                 PostgreSQL

Our next important step shouldn't be another framework.

It should be domain design.

Before creating the real V1 migration, we should determine Astra's core entities, relationships, constraints, indexes, and authorization model. Then we'll translate that model into a proper migration and corresponding JPA entities.

That will turn Astra from a Spring Boot demonstration project into an architecture project.

Comments

Popular posts from this blog

🌿 AN ARCHITECT'S FIELD GUIDE TO THE SPRING PLATFORM

Spring Forward: The Architecture-Defining Release Cycle of 2025-2026 How Spring Framework 7, Boot 4, Integration 7, Batch 6, and AI 2.0 are reshaping the way enterprise systems are designed, built, and operated — and what every tech lead needs to know before their next architecture review. The Spring ecosystem has never moved faster. Across six major projects — Framework, Boot, Integration, Batch, and AI — the teams at Broadcom have delivered what I consider the most consequential release cycle in the platform's history. This is not a changelog summary. It is an architectural assessment of what these changes mean for the systems we design, the teams we lead, and the technical decisions we will have to defend in production. 📊 Figure 1 — The Spring Platform at a Glance (2025-2026) Project Latest Version Headline Theme Key Innovations 🏗️ Spring Framework 7.0 Correct-by...

Inside the Top 5 Spring Boot Security Vulnerabilities (2026): Root Cause, Attack Surface, Patch Analysis & Mitigation

  Spring Boot remains one of the most trusted frameworks for building enterprise applications. However, even mature frameworks occasionally introduce security flaws through edge cases, configuration assumptions, or dependency interactions. This article analyzes five of the most impactful Spring Boot vulnerabilities disclosed recently—not just from a CVE perspective, but from an architect's point of view. For each vulnerability we'll cover: What was the bug? How could an attacker abuse it? (conceptually) What changed in the patch? How should enterprise teams mitigate it? 1. CVE-2026-22731 – Authentication Bypass via Health Group Path Severity: High (CVSS 8.2) What was the bug? Spring Boot Actuator allows custom health groups to be exposed under additional paths. Under specific configurations, Spring Boot generated request-matching rules that were broader than intended. As a result, protected application endpoints located beneath that path could mistakenly inherit the Actuator...

Project Aegis: From an Empty FTP Directory to a Root Shell — Exploiting vsFTPd 2.3.4 Manually

  Project Aegis | Cybersecurity Lab Sometimes the most interesting vulnerability isn't hidden in the files a service exposes. It's hidden in the service itself. During a recent Project Aegis lab session, I started with what looked like a fairly uninteresting FTP service on a deliberately vulnerable Metasploitable 2 virtual machine. Anonymous FTP login worked. The root directory appeared empty. There were no obvious files to download and nothing immediately interesting to enumerate. Yet, within the same service, I eventually obtained a root shell . The journey was a useful demonstration of a complete vulnerability-assessment workflow: reconnaissance → service identification → vulnerability research → automated validation → source inspection → manual reproduction → exploitation → impact verification Most importantly, it reinforced a lesson I want Aegis to teach: Don't just run security tools. Understand what they're actually doing. The Lab For this experiment, I used an ...