-
Notifications
You must be signed in to change notification settings - Fork 0
Postgres Migrations
This is a simple system for performing database schema migrations in SuperStack when self-hosting PostgreSQL.
It consists of:
- A
migrationsdirectory for your SQL scripts. - A
migratescript to perform migrations and keep track of completed ones. - A
startup.shwhich runs migrations it at first startup. - Some adjustments to the Postgres image.
mkdir -p db/postgres/migrations
mkdir -p db/postgres/bindb/postgres/bin/migrate
#!/bin/bash
set -euo pipefail
MIGRATIONS_DIR=/etc/superstack/migrations
APPLIED_MIGRATIONS_FILE="/var/lib/postgresql/data/.applied_migrations"
touch "$APPLIED_MIGRATIONS_FILE"
# Get sorted lists of all and applied migration filenames (no paths)
all_files=$(printf '%s\n' "$MIGRATIONS_DIR"/*.sql | xargs -n1 basename | sort)
applied_files=$(sort "$APPLIED_MIGRATIONS_FILE")
pending_files=$(comm -23 <(echo "$all_files") <(echo "$applied_files"))
# Apply pending migrations
if [[ -n "$pending_files" ]]; then
while IFS= read -r filename; do
echo "-- $filename" >&2
envsubst < "$MIGRATIONS_DIR/$filename" | \
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"
echo "$filename" >> "$APPLIED_MIGRATIONS_FILE"
done <<< "$pending_files"
else
echo "Nothing to deploy" >&2
fiMake it executable:
chmod +x db/postgres/bin/migrateAdd the following script to run migrations at startup:
mkdir -p db/postgres/docker-entrypoint-initdb.ddb/postgres/docker-entrypoint-initdb.d/startup.sh
#!/bin/bash
exec migrateMake it executable:
chmod +x db/postgres/docker-entrypoint-initdb.d/startup.shAdd a Dockerfile to customise your Postgres image:
db/postgres/Dockerfile
FROM postgres:17
COPY docker-entrypoint-initdb.d /docker-entrypoint-initdb.d
COPY migrations /etc/superstack/migrations
COPY bin /postgres-bin
ENV PATH="/postgres-bin:$PATH"
# gettext is needed for envsubst
RUN apt-get update && apt-get install -y \
gettext
# Cleanup after the install command
RUN rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# Lastly, set the WORKDIR - This is the default anyway but it may be changed
# above by the user when installing extensions
WORKDIR /var/lib/postgresqlFor development, mount some directories into the Postgres service:
db/compose.override.yaml
services:
postgres:
volumes:
- ./postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro
- ./postgres/migrations:/etc/superstack/migrations:rw
- ./postgres/bin:/postgres-bin:roLastly, remove the image: postgres from your Postgres service (or change it
to your own container registry url) and add a build: section:
services:
postgres:
build:
context: ./postgresThat's it!
Each file should be:
- An
.sqlfile - Numbered in order (e.g.
00-init.sql,01-extensions.sql,02-auth.sql) - Written in plain SQL
- But can include environment variables.
Here's an example migration script:
begin;
create schema api;
set search_path to api;
create table director (
id serial primary key,
name text not null
);
create table movie (
id serial primary key,
name text not null,
director_id integer references director(id)
);
commit;💡 Tip: It’s best to place your application tables inside a dedicated schema (like api) rather than the default public schema.
When the Postgres container starts with no existing data, SuperStack will automatically run migrations once.
After the first startup, migrations will only run if you manually apply them.
To apply your migrations, run:
bin/postgres migrateThis command will:
- Apply new migrations, in filename order.
-
Record applied migrations in a file named
.applied_migrations.
Already-applied scripts are skipped on subsequent runs.
💡
bin/postgresis a small script that effectively aliasesdocker compose exec postgres
Use begin; and commit; to wrap statements in a transaction. This ensures
that all changes are applied atomically. Any statements outside of transactions
will be auto-committed.
Avoid wrapping non-transactional operations in a transaction — these will
cause errors if used inside begin ... commit. Examples of
non-transactional statements include:
ALTER SYSTEM
CREATE DATABASE
CREATE EXTENSION
CREATE ROLE
CREATE TABLESPACE
DROP DATABASE
DROP EXTENSION
DROP TABLESPACEEnv vars can be used in migrations like this:
\set pgrst_authenticator_pass '$PGRST_AUTHENTICATOR_PASS'
create role authenticator noinherit login password :'pgrst_authenticator_pass';Putting them into variables first with \set reduces the chance of them being
logged.
SuperStack doesn’t enforce any particular migration file names or layout, but here’s a simple structure you might adopt:
00-init_postgrest.sql # Setup PostgREST (see PostgREST wiki page)
01-create_extensions.sql # "create extension" statements
02-public_functions.sql # Common functions used by other schemas/migrations
03-auth_schema.sql
04-core_schema.sql
05-jobs_schema.sql
10-api_schema.sql # The exposed schema, Views and RPC functions
11-... # Further iterations
90-roles.sql
91-grants.sql
While developing, you can reset and rebuild the database from scratch as often as needed:
docker compose down --volumes
docker compose up -dOnce you’ve deployed to production (or another persistent environment), avoid recreating the database. Instead:
- Add new migrations starting from
06-...onwards. - Apply them with:
bin/postgres migrateOr in other environments where bin/postgres isn't available:
docker compose exec postgres migrateThis approach keeps early development simple while providing a clear, ordered history once the database must be preserved.
If you want to start fresh, wipe your database and re-run all migrations from scratch:
docker compose down --volumes
docker compose up -d