Week 11

Deployment Strategies

Creating Docker Images

Multi-stage builds

Docker Compose

Container Registries

Deploying to the Cloud

Database Migrations

Practice

Assignment

Backend Track

Your schema is code too

Your application code lives in Git, with a full history: every change is a commit, and you can see who changed what and when. But your database schema โ€” the CREATE TABLE statements that define your tables โ€” has had no such history. So far you have run that SQL by hand, once, wherever you needed it.

That falls apart the moment more than one database exists. You already have at least two: the Postgres in your Docker Compose stack, and the Neon database you are about to deploy to. Soon there could be a teammate's copy as well. How do you make sure they all have the same tables? And when you add a column next week, how does every database get that change, in the right order?

The answer is database migrations: you treat every schema change as a small, numbered SQL file that lives in your project, in Git, next to your code.

<aside> ๐Ÿ’ก

You have already written your first migration without knowing it. The schema.sql you loaded into Postgres in the Docker Compose chapter is a schema change in a file. This chapter turns that one-off file into a proper, ordered system.

</aside>

The idea: numbered, forward-only files

Instead of one schema.sql that you keep editing, you keep a folder of change files, each with a version number and a description:

src/main/resources/db/migration/
  V1__create_customers_table.sql
  V2__create_orders_table.sql
  V3__add_email_to_customers.sql

Three rules make this work, and they are the whole concept:

  1. Numbered โ€” files run in order: V1, then V2, then V3. The order is not optional! V3 might add a column to a table V1 created.
  2. Forward-only โ€” you only ever add new files. To change something, you write a new migration. You never go back and rewrite V2 to add columns to it.
  3. Immutable once applied โ€” the moment a migration has run on a database that others use (like your deployed Neon database), it is frozen. Fixing a mistake means a new migration, not an edit.

<aside> โ—

Why never edit an applied migration? Because a database only runs each file once. If you edit V2 after it has already run, the databases that ran the old V2 will never see your change โ€” but a fresh database will run your new V2. Now your databases silently disagree about their own structure. Forward-only is the rule that prevents this.

</aside>

What a migration looks like

A migration is just plain SQL โ€” the same CREATE TABLE and ALTER TABLE you already know from Week 5:

-- V2__create_orders_table.sql
CREATE TABLE orders (
    id           TEXT PRIMARY KEY,
    customer_id  TEXT NOT NULL REFERENCES customers(id),
    total_cents  INTEGER NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

A later change is a new file, never an edit of the one above:

-- V3__add_email_to_customers.sql
ALTER TABLE customers ADD COLUMN email TEXT;

Applying them by hand

Running a migration against a database is just running its SQL file. Against your Neon database, using psql with the connection string:

psql "$DATABASE_URL" -f src/main/resources/db/migration/V2__create_orders_table.sql

You run each new file, in order, against each database that needs it. On a fresh database you run them all from V1; on a database that already has V1 and V2, you run only V3.

The piece everyone forgets: tracking what ran

There is one hard part. Look at the last sentence above: "a database that already has V1 and V2". How do you know which migrations a given database has already run? If you lose track, you will either skip a migration or run one twice โ€” both break your database.

The standard solution is simple: the database keeps a little table listing the migrations it has run.

CREATE TABLE schema_version (
    version     TEXT PRIMARY KEY,
    applied_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

Every time you apply a migration, you record it:

INSERT INTO schema_version (version) VALUES ('V2');

Now, to update any database, the recipe is always the same: look at schema_version to see what it already has, then run every migration file that is not listed yet, in order, recording each one.

<aside> ๐Ÿ’ก

This tracking table is exactly what migration tools build and manage for you automatically. Tools like Flyway and Liquibase (both common in Java and Spring Boot projects) scan your db/migration folder, check their version table, and apply whatever is missing โ€” in one command. You are doing by hand what they automate, so that you understand what they are actually doing. You do not need to set one up in this course.

</aside>

The golden rule for deployment

This is why the chapter sits here, right after deployment. When you ship a new version of your app that expects a new column, the order matters:

<aside> โ—

Run the migration before you deploy the app version that needs it. If the new code goes live first and the column does not exist yet, every request that touches it fails until the migration catches up. Schema first, then the code that depends on it.

</aside>

That means: run your migrations against Neon first (by hand, with psql or the Neon SQL Editor), confirm the tables are there, and only then deploy your app on Render.

A few habits that avoid pain

Extra resources


The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.