Schema migration tools keep your database structure in version control, next to the application code. Each change is written once as a migration file, applied in order, and recorded in a history table so it never runs twice. In this tutorial you will install Flyway and Liquibase on Ubuntu 24.04, use both against a PostgreSQL database, apply and roll back changes, and see how to run them from a CI/CD pipeline.
Prerequisites
To follow this tutorial, you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM.
- A non-root user with
sudoprivileges. - PostgreSQL installed locally or reachable over the network. Step 1 installs it locally if you do not have it yet.
Flyway and Liquibase at a glance
Both tools solve the same problem, with a different philosophy:
| Flyway | Liquibase | |
|---|---|---|
| Migration format | Plain SQL files named by version (V1__...sql) | Changelogs in SQL, XML, YAML or JSON |
| Ordering | Version number in the file name | Order of changesets in the changelog |
| History table | flyway_schema_history | databasechangelog and databasechangeloglock |
| Rollback | undo only in paid editions; write a new forward migration | Built in, with rollback blocks per changeset |
| Preview SQL before applying | migrate dry run is a paid feature | update-sql in the open source edition |
| Best fit | SQL-first teams that want minimal ceremony | Teams that need rollbacks, previews or multi-database changelogs |
You do not need both in one project. This tutorial installs both so you can compare them on the same database; each uses its own database to keep their history tables separate.
Step 1 - Preparing PostgreSQL
If PostgreSQL is not installed yet, install it from the Ubuntu repositories:
sudo apt update
sudo apt install postgresql
Create a dedicated role for migrations and one database per tool. Replace your_strong_password with a real password:
sudo -u postgres psql -c "CREATE ROLE migrator LOGIN PASSWORD 'your_strong_password';"
sudo -u postgres psql -c "CREATE DATABASE flyway_demo OWNER migrator;"
sudo -u postgres psql -c "CREATE DATABASE liquibase_demo OWNER migrator;"
Check that the new role can log in over TCP, which is how both tools connect through JDBC:
psql "postgresql://migrator:your_strong_password@localhost:5432/flyway_demo" -c "SELECT current_user;"
current_user
--------------
migrator
(1 row)
Step 2 - Installing Flyway
The Flyway command-line tarball for Linux ships with its own Java runtime and the PostgreSQL JDBC driver, so nothing else is needed. Check the latest open source release on the Flyway releases page and set it in a variable:
FLYWAY_VERSION=11.0.0
Replace 11.0.0 with the current version, then download and extract Flyway into /opt:
cd /tmp
wget "https://download.red-gate.com/maven/release/com/redgate/flyway/flyway-commandline/${FLYWAY_VERSION}/flyway-commandline-${FLYWAY_VERSION}-linux-x64.tar.gz"
sudo tar -xzf "flyway-commandline-${FLYWAY_VERSION}-linux-x64.tar.gz" -C /opt
sudo ln -sf "/opt/flyway-${FLYWAY_VERSION}/flyway" /usr/local/bin/flyway
Verify the installation:
flyway -v
The command prints the Flyway edition and the version you downloaded. If it fails with No such file or directory, check that the symbolic link points to the extracted folder in /opt.
Step 3 - Writing and applying Flyway migrations
Create a project directory with a sql folder for migrations:
mkdir -p ~/flyway-demo/sql
cd ~/flyway-demo
Flyway reads its settings from FLYWAY_* environment variables, which keeps the password out of files you commit. Create a small environment file:
nano ~/flyway-demo/.env
export FLYWAY_URL=jdbc:postgresql://localhost:5432/flyway_demo
export FLYWAY_USER=migrator
export FLYWAY_PASSWORD=your_strong_password
export FLYWAY_LOCATIONS=filesystem:sql
Load it into your shell and keep the file out of version control:
chmod 600 .env
source .env
Versioned migrations follow the pattern V<version>__<description>.sql, with two underscores. Create the first one:
nano sql/V1__create_users_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Add a second migration that changes the table:
nano sql/V2__add_is_active_to_users.sql
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
CREATE INDEX idx_users_created_at ON users (created_at);
Check what Flyway sees before applying anything. The output is a table similar to this (recent versions add more columns):
flyway info
+-----------+---------+-----------------------+------+--------------+---------+
| Category | Version | Description | Type | Installed On | State |
+-----------+---------+-----------------------+------+--------------+---------+
| Versioned | 1 | create users table | SQL | | Pending |
| Versioned | 2 | add is active to users| SQL | | Pending |
+-----------+---------+-----------------------+------+--------------+---------+
Apply the pending migrations:
flyway migrate
Successfully validated 2 migrations (execution time 00:00.021s)
Creating Schema History table "public"."flyway_schema_history" ...
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version "1 - create users table"
Migrating schema "public" to version "2 - add is active to users"
Successfully applied 2 migrations to schema "public", now at version v2
Run flyway info again and both rows show the state Success. Running flyway migrate a second time does nothing, because the history table already records both versions.
Rules that keep Flyway predictable
- Never edit a migration that has been applied. Flyway stores a checksum of each file, and
flyway validate(run automatically bymigrate) fails if a file changed. To change the schema again, addV3__.... - To undo a change in the open source edition, write a new forward migration that reverses it. The
undocommand andUfiles require a paid Flyway edition. - If a migration fails halfway on a database without transactional DDL, fix the problem manually and run
flyway repairto clean the failed entry from the history table. - To start using Flyway on an existing database, run
flyway baselineonce so Flyway treats the current schema as version 1.
Step 4 - Installing Liquibase
Liquibase publishes an official APT repository. It needs a Java runtime, so install one first:
sudo apt install openjdk-21-jre-headless gnupg
Add the Liquibase signing key and repository:
sudo install -m 0755 -d /etc/apt/keyrings
wget -qO- https://repo.liquibase.com/liquibase.asc | sudo gpg --dearmor -o /etc/apt/keyrings/liquibase.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/liquibase.gpg] https://repo.liquibase.com stable main" | sudo tee /etc/apt/sources.list.d/liquibase.list
Install Liquibase:
sudo apt update
sudo apt install liquibase
Verify it runs:
liquibase --version
The output starts with the Liquibase banner and ends with the installed Liquibase and Java versions.
Step 5 - Writing and applying Liquibase changelogs
Liquibase starts from a master changelog that includes the individual change files. Create the project layout:
mkdir -p ~/liquibase-demo/changes
cd ~/liquibase-demo
Liquibase automatically reads liquibase.properties from the current directory:
nano liquibase.properties
changelog-file=changelog-master.yaml
url=jdbc:postgresql://localhost:5432/liquibase_demo
username=migrator
Pass the password through an environment variable instead of the file:
export LIQUIBASE_COMMAND_PASSWORD=your_strong_password
Create the master changelog:
nano changelog-master.yaml
databaseChangeLog:
- include:
file: changes/001-create-users.sql
relativeToChangelogFile: true
- include:
file: changes/002-add-is-active.sql
relativeToChangelogFile: true
Each included file uses the formatted SQL syntax: a header line, then one or more changesets identified by author:id, each with an optional rollback statement:
nano changes/001-create-users.sql
--liquibase formatted sql
--changeset alice:001-create-users
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--rollback DROP TABLE users;
nano changes/002-add-is-active.sql
--liquibase formatted sql
--changeset alice:002-add-is-active
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
--rollback ALTER TABLE users DROP COLUMN is_active;
List the changesets that have not been applied yet:
liquibase status --verbose
2 changesets have not been applied to migrator@jdbc:postgresql://localhost:5432/liquibase_demo
changes/001-create-users.sql::001-create-users::alice
changes/002-add-is-active.sql::002-add-is-active::alice
Preview the exact SQL Liquibase will run, which is useful for a review before touching production:
liquibase update-sql
Then apply the changes:
liquibase update
Running Changeset: changes/001-create-users.sql::001-create-users::alice
Running Changeset: changes/002-add-is-active.sql::002-add-is-active::alice
Liquibase command 'update' was executed successfully.
Confirm what was recorded:
liquibase history
The output lists both changesets with their deployment ID and execution date.
Step 6 - Rolling back with Liquibase
Rollbacks are where Liquibase differs most from Flyway's open source edition. Tag the current state first, so you have a named point to return to:
liquibase tag --tag=v1.0
Undo the most recent changeset:
liquibase rollback-count --count=1
Liquibase runs the --rollback statement of 002-add-is-active and removes its row from databasechangelog. Check the result:
psql "postgresql://migrator:your_strong_password@localhost:5432/liquibase_demo" -c "\d users"
The is_active column is gone. Apply it again with liquibase update, and later you can return to the tagged state with liquibase rollback --tag=v1.0. As with update, you can preview a rollback without executing it using liquibase rollback-count-sql --count=1.
WarningA rollback that drops a column or table also deletes its data. Treat rollbacks as an emergency tool and test them on a copy of production data first.
Step 7 - Running migrations from CI/CD
Both tools are designed to run non-interactively, so a pipeline only needs the CLI, the migration files from the repository and the database credentials from the CI secret store. A typical flow:
- On every pull request, run migrations against a throwaway database (a PostgreSQL service container in the CI job) to prove they apply cleanly from scratch.
- Before deploying, run
flyway validateorliquibase status --verboseagainst the target database to see exactly what will change. - Apply with
flyway migrateorliquibase updateas a dedicated deploy step, before the new application version starts.
Only one process should migrate a database at a time. Both tools lock their history table while running, but starting migrations from several application replicas at boot is still a common source of slow or failed deploys; a single pipeline step avoids it.
Troubleshooting
Validate failed: Migration checksum mismatch(Flyway): a file that was already applied has changed. Restore the original file from Git and put the change in a new migration. Useflyway repaironly if the edit was intentional and harmless, such as a comment.Cannot find database driver: org.postgresql.Driver(Liquibase): your Liquibase build does not bundle the PostgreSQL driver. Download the driver JAR from Maven Central (org.postgresql:postgresql) into the project directory and addclasspath=postgresql-<version>.jartoliquibase.properties.Waiting for changelog lock(Liquibase): a previous run was killed while holding the lock. After confirming no other migration is running, runliquibase release-locks.FATAL: password authentication failed: check the credentials with thepsqlcommand from Step 1 before debugging the tools.
Conclusion
You installed Flyway and Liquibase on Ubuntu 24.04, applied the same schema with each, and used Liquibase to preview and roll back changes. Choose Flyway if your team writes plain SQL and fixes mistakes with forward migrations; choose Liquibase if you need built-in rollbacks, SQL previews or database-independent changelogs. As next steps, commit the migration folder to the application repository, add a migration job to your CI pipeline, and practice restoring a PostgreSQL backup so a failed migration never becomes data loss.
