1. Database schema after app + postgres
When you first brought up app + postgres and saw that the PostgreSQL container was healthy, a false sense of victory appears: “Well, the database is there, so the application should work.” It is roughly like being happy that you have a refrigerator — before you have checked whether there is food in it and whether it has been unplugged. For the backend, “the database is up” is only half the story.
Let us separate two similar, but fundamentally different, ideas. The first: PostgreSQL as a process is running and ready to accept TCP connections on port 5432. This is about the network and readiness. The second: PostgreSQL as a database contains the required tables, indexes, constraints, and all of it matches the expectations of our application. That is already about the schema and its state.
In our end-to-end project Container-Ready Catalog Service, the application in the postgres profile uses JPA and expects the database to contain tables like catalog_item and, most likely, a table for export jobs. If these tables are missing, even a perfectly healthy PostgreSQL turns into a “very self-confident empty notebook” for the application: the connection exists, but there is nothing to work with.
And this is where a mature engineering idea appears: the state of the database schema should be part of the reproducible startup, just like compose.yaml is part of the reproducible container startup. Otherwise, Docker seems to solve the problem of “how to start the service,” but leaves a hole in “how to bring the data into the right shape.”
2. Schema migrations without “DevOps magic”
When people hear the word “migrations,” beginners often get an alarm signal: “Oh, that is something from the world of database administrators; I am not allowed there.” In reality, in our context migrations are a very developer-friendly thing. They are a way to store schema changes as code, next to your application, so that any developer can get the correct database state with a single startup command.
A database schema is, in simple terms, “how the tables are arranged”: what they are, what fields they contain, what constraints they have, what indexes they have. If in Java code you add a new field to an entity (or the business simply says, “We need to store the product status”), then the database must receive the corresponding change. And that is what a migration is — a small SQL script (or another format, but in this course we stick to SQL) that describes one specific change: create a table, add a column, create an index, fill in minimal seed data.
The key point: migrations are not a “one-time database preparation done manually by the course author.” Migrations are the source of truth for what the schema should be. They live in the repository, travel together with the code, and are executed automatically at application startup (in our case, through Flyway). That means a new developer (or you, a week later, when everything has been forgotten) will not have to ask in chat, “Who remembers which tables were supposed to be created?”
Yes, migrations are not about “beautiful theory.” They are about plain old saving your nerves. Because manually creating tables in a local database almost always ends with each developer having a slightly different schema. And “slightly” for a database is like “slightly different password”: it does not work.
3. Flyway in the Spring Boot startup
After the word “migrations,” the next question usually appears: “Fine, migrations exist. But who runs them?” And here Flyway acts like a very disciplined manager: it comes to work earlier than everyone else, checks what has already been done, performs the missing changes, and only then allows the application to truly start. And it does this not as a separate utility somewhere on the side, but as part of the normal Spring Boot application startup.
In our project Flyway is a library. You add the dependency, Spring Boot sees it on the classpath, sees that there is a DataSource, and automatically includes the migration step in the startup pipeline. This is similar to the auto-configuration we are already used to: you do not manually write “create Flyway for me and call migrate(),” you just enable the needed dependency and provide a correct configuration.
It is very important to fix the sequence. In the container world, an application is considered “alive” as long as the main process is alive. For Spring Boot, that is the JVM process. If Flyway cannot apply migrations at startup, Spring Boot usually does not reach the “Started … in N seconds” state, the context does not come up, the process terminates, and the application container either stops or gets restarted (if you enabled a restart policy, but in our basic learning setup we are not focusing on that).
It helps to imagine the startup as a chain of steps where each next one depends on the previous:
flowchart TD
%% Important: Postgres healthcheck is about the network and process availability, not about schema readiness
A["docker compose up"] --> B["PostgreSQL container starts"]
B --> C["Healthcheck (pg_isready) = healthy"]
%% Flyway is built into the application startup and runs BEFORE the final Started
C --> D["App container starts"]
D --> E["Spring Boot brings up the ApplicationContext"]
E --> F["DataSource is created (connection to postgres)"]
F --> G["Flyway applies migrations"]
G --> H["The application reaches Started + begins listening on the port"]
Notice the subtlety: database readiness (pg_isready) guarantees only that the database is ready to accept connections, but does not guarantee that the schema is in the correct state. Flyway is the next “checkpoint” that turns a merely reachable database into a database suitable for the application to work with.
Before that, there may have been simpler approaches locally: someone creates tables by hand, someone leaves spring.jpa.hibernate.ddl-auto on so Hibernate can adjust the schema on its own. For a quick experiment this can sometimes be tolerated, but in the postgres course mode, Flyway becomes the owner of schema evolution. Hibernate should not compete in parallel with migration files, otherwise the project gets two sources of truth: one lives in SQL files, the other in ORM runtime behavior.
4. Connecting Flyway and the postgres profile
Now let us ground the idea in our Container-Ready Catalog Service. Here it is important to see that Flyway does not “live in a separate Docker Compose container” by default, it is not “some utility you run manually,” and it does not require a separate service in compose.yaml. In our educational baseline, Flyway is part of the application, which means it lives where the code lives.
At the Gradle level, this looks very ordinary: add the dependency — and that is it. In build.gradle.kts it may look roughly like this (highly simplified, only what matters now):
dependencies {
// JPA: in the postgres profile we work through ORM and DataSource
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// Flyway: at startup it brings the schema to the expected state
implementation("org.flywaydb:flyway-core")
// Postgres driver: without it, the application simply cannot connect to the database
runtimeOnly("org.postgresql:postgresql")
}
The idea is simple: we need JPA to work with Postgres in the postgres profile, the PostgreSQL driver is needed so that there is something to connect with at all, and Flyway is needed to bring the schema to the expected state at startup. Spring Boot, through auto-configuration, combines this into a single mechanism.
Next, Flyway must connect to the very same database as the application. Therefore, in the postgres profile (for example, in application-postgres.yml), Flyway sits next to the datasource settings, and that is very logical. Roughly like this:
spring:
datasource:
# JDBC URL for Postgres
url: jdbc:postgresql://postgres:5432/catalog
# Account used by the application to connect to the database
username: catalog
password: catalog
flyway:
# In the learning project, migrations are part of startup (by default this is true anyway, but explicit is calmer)
enabled: true
Notice that the host here is postgres, that is, the service name inside Compose. This is the same principle we established earlier: inside Compose, localhost almost always means “this container itself,” not “the neighboring service.”
And finally, the most “container-ish” part: Flyway only starts to matter when the application is actually running in postgres mode, that is, when the postgres profile is active. In compose.yaml, we do this through an environment variable. In the minimal form, it looks like this:
services:
app:
environment:
# Enable the profile that uses an external DataSource and, accordingly, Flyway
SPRING_PROFILES_ACTIVE: postgres
depends_on:
postgres:
# Wait until Postgres is ready to accept TCP connections
condition: service_healthy
This is a very illustrative place: Compose guarantees order and minimal dependency readiness, the Spring Boot profile switches the storage mode, and Flyway becomes part of the application startup path exactly in this mode.
5. Ready signals and application readiness
When we were learning the readiness model, we fought the effect of “sometimes it starts, sometimes it does not.” We added a healthcheck, made depends_on.condition: service_healthy, and now the application stopped failing just because the database “had not woken up yet.” That was an important step. But it still solves only the network side of readiness.
With Flyway, a second layer of readiness appears — the logical one. The application may connect to the database perfectly well, but still fail because migrations were not applied. And that is not a Docker bug and not a “Spring whim,” it is an honest signal: the database is available, but bringing it into the expected state did not work.
To avoid confusion, it is useful to keep a simple difference table in mind. It is very “non-academic,” but it does an excellent job of saving you from mental chaos:
| Signal | What it says | What it does not guarantee |
|---|---|---|
| postgres = healthy | PostgreSQL is ready to accept connections | That the schema has already been created and matches the application expectations |
| the app container is running | The JVM process started | That the application reached a working state and began answering HTTP |
| the logs contain Successfully applied ... migrations | Flyway successfully applied the migrations | That there will be no problems afterwards (but at least the schema came up) |
| the logs contain Started ... | Spring Boot reached the “started” state | That a specific business endpoint works (but that is already another layer of checks) |
From a practical point of view, this means that starting today the word “started” should be used more carefully. “Postgres started” is one thing. “The application started” is another. And “the application started and the migrations passed” is already a third, more mature level of confidence.
And yes, this is exactly the moment when a developer stops looking at the container as a “magic box” and starts seeing a chain of cause and effect. Sometimes that is a little sad, because now we have more responsibility. But it is also joyful, because now we have less magic and more control.
6. Logs and the Flyway workflow
Successful startup: Flyway logs
We are already used to thinking of logs not as “noise,” but as a vitally important signal. With Flyway this becomes even more important, because migrations are part of startup. Therefore, a successful startup of the application in postgres mode now has a characteristic pattern in the logs: first Spring Boot begins to come up, then a Flyway block appears, then the application reaches “Started.”
In reality, the lines may differ (versions, logger format, details), but the meaning is usually recognizable. An example of how it logically looks when startup succeeds:
# JVM/application startup
app-1 | Starting CatalogApplication
# Flyway has started and began checking/applying migrations
app-1 | Flyway Community Edition ...
# Key signal: migrations were applied successfully
app-1 | Successfully applied 2 migrations
# Final signal: Spring Boot reached Started
app-1 | Started CatalogApplication
What matters in this fragment is not that there are “2 migrations” there (that number will change as the project evolves), but that the sequence shows: migrations happen between the start of startup and the final “Started.”
And this is where beginners often break their old reflex: “I saw that the app container is in the Up status, so everything is fine.” That is no longer quite true. The container may indeed be in the Up status for the first few seconds, but if Flyway fails, Spring Boot will not come up, the process will end, and the container will become Exited. So looking at logs is not something you do only “when everything is bad,” it is a normal part of startup.
For Compose this is especially convenient, because you can read the log stream for the whole stack at once. For example, at one moment you will see that Postgres came up, and then the application applied its migrations. (We studied commands earlier; here we are simply reinforcing the idea that we need them every day.)
Flyway in the workflow
At some point you want to ask: “Can we just bring the database up once, create the tables manually, and never suffer again?” You can. That is exactly how thousands of projects were done, and exactly why thousands of projects then suffered. Because a manual database state is like “I definitely remember the password, I did not write it down.” Until the first vacation, laptop replacement, or new colleague.
Docker and Compose made our environment startup reproducible at the container level: the same Postgres version, the same ports, the same env vars, the same commands. Flyway makes reproducible what happens inside the database: tables, their structure, minimal initial state, and the sequence of changes.
This idea is especially important together with a PostgreSQL named volume. We already know that database data lives longer than the container. That means the database can survive dozens of up/down cycles, and along with it the schema and migration history survive too. On the next startup, Flyway will not “create everything from scratch every time”; it knows which migrations have already been applied and which have not, and it runs only the missing ones. As a result, the local environment becomes not just “launchable,” but “living through a comprehensible evolution.”
And that is why today we call Flyway part of the application startup. Because as a habit, it should look like this: you bring up the stack — Flyway runs — the schema matches the code version — you work on the task instead of doing magic around the database.
One working thought is enough here: migrations are just as much a project artifact as Dockerfile or compose.yaml, and Flyway is the mechanism that makes this artifact executable at startup.
7. Common mistakes when working with Flyway
In this topic, mistakes often happen not because it is “hard,” but because different levels of readiness get mixed up in the head: containers, network, database as a process, and database as a schema. On top of that, the habit of “quickly fixing it by hand” gets in the way — on a short distance it looks like salvation, and on a long one it becomes a permanent mystery. Let us go through the most common traps.
Mistake #1: treating Flyway as a separate manual step “on the side.”
A beginner sometimes thinks that migrations have to be run separately: with a separate command, a separate utility, a separate container. In real teams this is sometimes done, but in our educational baseline Flyway is built into the Spring Boot application and works as part of startup. This is important methodologically: you should see migrations in the logs next to startup and understand that without them the application is not “ready.”
Mistake #2: assuming that if PostgreSQL is healthy, the application must start.
The database healthcheck solves only the readiness of the database as a service. But the application may fail because migrations were not applied or were not applied fully. That is unpleasant, but honest: the database is available, but the schema is not ready. From this day on, “healthy postgres” means “you can begin application startup,” not “everything is already ready.”
Mistake #3: expecting migrations in any profile, including standalone.
In standalone mode we do not have an external database: storage is in-memory, startup is fast, and dependencies are minimal. Therefore, Flyway is a story specifically for the postgres profile, where there is a real DataSource. If you run standalone and “do not see Flyway in the logs,” that is not a failure, but the correct behavior.
Mistake #4: “fixing” migration problems by turning Flyway off.
Sometimes you want to set spring.flyway.enabled=false so the application will “at least start.” That is like the idea “the light bulb burned out — I will tape over it.” The application may go further, but then it will start failing when working with tables, and you will lose the main source of truth about the schema. If migrations break, that is a signal to fix the source of truth (the migrations), not to disable the mechanism.
Mistake #5: changing the schema manually in the database and forgetting to update migrations.
Manual changes in a local database create “state drift”: your schema is one thing, your neighbor’s is another, CI’s is a third (if you even have CI), and your future self a week later has a fourth. Flyway is designed as a way to keep the schema versioned and reproducible. The moment you make a “small manual tweak” and do not capture it in a migration, you are back in the world of chaos that Docker/Compose were supposed to save us from.
GO TO FULL VERSION