Spring Boot + PostgreSQL + Flyway: Database Migration Workflow on DomainIndia VPS
Why you need migrations
Without a migration tool, schema changes are manual. Symptoms:
- "I forgot to run that ALTER TABLE on staging"
- Dev DB has columns production doesn't (or vice versa)
- Rolling back a release doesn't roll back the DB
- New team member's local DB is half-set-up
With Flyway:
- Every schema change is an SQL file committed to git
flyway migratereplays missing changes in orderflyway infoshows which migrations ran on each DB- DB schema versioned alongside app code
Flyway vs Liquibase
| Tool | Format | Maturity | Best for |
|---|---|---|---|
| Flyway | Plain SQL (+ Java-based) | Mature, simpler | Most Spring Boot apps |
| Liquibase | XML/YAML/SQL | Older, more features | Cross-DB projects, complex rollbacks |
Pick Flyway for most Spring Boot apps. Plain SQL is readable, reviewable, standard.
Step 1 — Add to Spring Boot
pom.xml:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>Spring Boot auto-configures Flyway — it runs migrations on startup.
application.yml:
spring:
datasource:
url: jdbc:postgresql://localhost/myapp
username: spring
password: ${DB_PASSWORD}
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
validate-on-migrate: trueStep 2 — Migration file structure
Create src/main/resources/db/migration/:
V1__create_users.sql
V2__create_orders.sql
V3__add_user_phone.sql
V4__seed_default_roles.sql
V5__index_orders_created_at.sqlNaming:
V(uppercase) prefix- Version number (integer or dotted:
V1,V1.1,V2) __(two underscores)- Description (letters, numbers, underscores)
.sqlextension
Never edit a migration after it's run. Always add a new migration. Flyway tracks applied migrations + their checksums.
Step 3 — Example migrations
V1__create_users.sql:
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
password_hash text NOT NULL,
name text NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX idx_users_email ON users(email);V3__add_user_phone.sql:
ALTER TABLE users ADD COLUMN phone text;
CREATE INDEX idx_users_phone ON users(phone) WHERE phone IS NOT NULL;Partial indexes (WHERE phone IS NOT NULL) are a Postgres gem — smaller index, faster writes. Use them for sparse columns.
Step 4 — Running migrations
In dev: Spring Boot runs them on app startup automatically.
In production: disable auto-run in app (spring.flyway.enabled: false) and run as a separate step in your deploy:
./mvnw flyway:migrate -Dflyway.url=$DATABASE_URL -Dflyway.user=$DB_USER -Dflyway.password=$DB_PASSWORDOr via the Flyway CLI:
flyway -url=$DATABASE_URL -user=$DB_USER -password=$DB_PASSWORD migrateWhy separate? Web servers restart unexpectedly. Running migrations on startup means a slow migration blocks health checks → load balancer kills the app → migration restarts partway through → broken DB.
Step 5 — systemd migration unit
Create a one-shot systemd service that runs migrations once, before the app starts:
/etc/systemd/system/spring-app-migrate.service:
[Unit]
Description=Flyway migrations
Before=spring-app.service
Requires=postgresql.service
[Service]
Type=oneshot
User=spring
WorkingDirectory=/opt/spring-app
ExecStart=/usr/bin/java -jar /opt/spring-app/app.jar
--spring.flyway.enabled=true
--spring.flyway.repair-on-migrate=false
EnvironmentFile=/opt/spring-app/.envIn spring-app.service:
[Unit]
After=network.target postgresql.service spring-app-migrate.service
Requires=spring-app-migrate.serviceNow restart: sudo systemctl restart spring-app — migrations run first, then the app starts.
Step 6 — Rollback strategy
Flyway's undo feature is Teams-tier only. For OSS, roll back via forward migrations:
-- V10__drop_user_phone.sql (forward-only rollback of V3)
ALTER TABLE users DROP COLUMN phone;Better pattern: design migrations to be backward-compatible:
- Adding a column — always compatible. Old code ignores the new column.
- Removing a column — do it in 2 releases:
1. Release N: stop writing to the column in code
2. Release N+1 (after verifying): drop the column via migration
- Renaming a column — do it in 3 steps:
1. Add new column, dual-write
2. Backfill old data to new column
3. Remove old column
This way, rolling back a bad code deploy doesn't require a DB rollback.
Step 7 — Seed data
For development fixtures:
V4__seed_default_roles.sql:
INSERT INTO roles (name, permissions) VALUES
('admin', '{"*": true}'),
('user', '{"read": true}'),
('guest', '{}')
ON CONFLICT (name) DO NOTHING;Use ON CONFLICT so re-running doesn't fail. Idempotent.
For test-only data, prefer Spring's @Sql test annotation or data.sql, not Flyway — test data shouldn't land in prod.
Step 8 — Repeatable migrations
For views/functions/triggers that you edit frequently, use R__ prefix:
R__update_search_view.sql:
CREATE OR REPLACE VIEW search_items AS
SELECT id, name, description, created_at FROM products
UNION ALL
SELECT id, title, body, published_at FROM articles;Flyway detects changes via checksum and re-runs. Useful for views and stored procedures.
Step 9 — CI/CD integration
GitHub Actions:
- name: Validate migrations
run: ./mvnw flyway:validate -Dflyway.url=${{ secrets.STAGING_DB_URL }}
- name: Dry run
run: ./mvnw flyway:info -Dflyway.url=${{ secrets.STAGING_DB_URL }}
- name: Run on staging
if: github.ref == 'refs/heads/develop'
run: ./mvnw flyway:migrate -Dflyway.url=${{ secrets.STAGING_DB_URL }}
- name: Run on production (manual approval)
if: github.ref == 'refs/heads/main'
environment: production # requires approver in GH
run: ./mvnw flyway:migrate -Dflyway.url=${{ secrets.PROD_DB_URL }}Monitoring migrations
Check state:
./mvnw flyway:info -Dflyway.url=$DATABASE_URLOutput:
+------------+---------+---------------+---------+---------------------+----------+
| Category | Version | Description | Type | Installed On | State |
+------------+---------+---------------+---------+---------------------+----------+
| Versioned | 1 | create users | SQL | 2026-01-15 10:00:00 | Success |
| Versioned | 2 | create orders | SQL | 2026-01-16 11:00:00 | Success |
| Versioned | 3 | add user phone| SQL | 2026-02-01 14:00:00 | Success |
| Versioned | 4 | seed roles | SQL | | Pending |
+------------+---------+---------------+---------+---------------------+----------+Common pitfalls
FAQ
Flyway for most Spring Boot apps — simpler, plain SQL. Liquibase if you need cross-DB abstraction (same XML runs on Postgres and Oracle), or first-class rollback support.
Yes — Flyway has a CLI, Docker image, Gradle plugin, Maven plugin. Common in CI/CD pipelines where you don't want the JVM overhead.
Yes — swap flyway-database-postgresql for flyway-database-mysql. SQL syntax differences between DBs are up to you.
Keep under 1 MB. Huge seed data should be in separate files or loaded via COPY FROM + CSV.
Depends — shared hosting with Node.js apps uses npm node-pg-migrate. For Spring Boot specifically, you need VPS (Java can't run on shared).
Production Spring Boot + Flyway needs a solid VPS with PostgreSQL. View VPS plans