PostgreSQL Database and Migrations
Learning Objectives
- You know how to add a PostgreSQL database to a project.
- You know how to use Flyway to manage database migrations.
Next, we add PostgreSQL database to the walking skeleton after which we add database migrations to the project. We use Docker to run the database and Flyway to manage the migrations.
Environment file
First, create a file called project.env
and place it to the same folder with the compose.yaml
file. With the file in place, the folder structure of the project is as follows.
tree --dirsfirst
.
// ..
├── compose.yaml
└── project.env
Copy the following contents to the project.env
file.
POSTGRES_USER=username
POSTGRES_PASSWORD=password
POSTGRES_DB=database
The file define environment variables that are used to configure the database. PostgreSQL expects three variables: POSTGRES_USER
, POSTGRES_PASSWORD
, and POSTGRES_DB
. Perhaps unsuprisingly, these are used to define the username, the password, and the database name.
In production, we would use more secure credentials.
Adding the database
As PostgreSQL is available as a Docker image, it can be added easily to the project. Let’s use database
as the service name, and add a configuration container_name
that allows us to easily find the container — we’ll call the container postgresql_database
. For the database service, we define the image as postgres:17.0
, and add a policy to restart the service unless it has been stopped. Finally, we also define an environment file — using env_file
for the project. This definition looks as follows.
database:
container_name: postgresql_database
image: postgres:17.0
restart: unless-stopped
env_file:
- project.env
When using a PostgreSQL image, there are a wide variety of options to choose from, as outlined at https://hub.docker.com/_/postgres. In our case, we use the version 17.0.
PostgreSQL Docker images follow a naming
postgres:version
, whereversion
is the version number. There are also additional image types, but we do not care about these.
With the above configuration, we want to also adjust our server
service so that it depends on the database. This is done by adding depends_on
configuration for the server
service. The depends_on
configuration is given the service that is being depended on as a value — in this case, the value is database
, as our database service is called database
.
With this in place, the api
service configuration looks as follows.
server:
build: server
restart: unless-stopped
volumes:
- ./server:/app
ports:
- 8000:8000
depends_on:
- database
And the whole compose.yaml
file is as follows.
services:
client:
build: client
restart: unless-stopped
volumes:
- ./client/src:/app/src
ports:
- 5173:5173
depends_on:
- server
database:
container_name: postgresql_database
image: postgres:17.0
restart: unless-stopped
env_file:
- project.env
e2e-tests:
entrypoint: "/bin/true"
build: e2e-tests
network_mode: host
depends_on:
- client
volumes:
- ./e2e-tests/tests:/app/tests
server:
build: server
restart: unless-stopped
volumes:
- ./server:/app
ports:
- 8000:8000
depends_on:
- database
Specifying a name for a container is optional. If a name is not specified, Docker generates a name for the container. The name is generated by combining the name of the root folder (e.g., docker-project
) and the name of the service (e.g., database
). For the root folder docker-project
and the service database
, the generated name would be docker-project_database_1
. The number at the end is generated by Docker, and it is used to differentiate between multiple containers of the same service.
When we use an exact container name, it is easier to find, stop, and remove the container. For example, we can stop the container with the docker stop postgresql_database
command, and remove it with the docker rm postgresql_database
command. At the same time, if there are multiple docker projects with the same container name, there will be a clash, and the container will not be started.
Accessing the database from the terminal
Now that the database has been added to the compose.yaml
file, we can launch the project with the docker compose up --build
command. This builds all the services and also downloads the PostgreSQL image. When we run the command, we see plenty of log information, which highlights — at the end — that the database is also running.
The database running in a container can be accessed with Docker using the command docker exec -it postgresql_database psql -U username database
. This means that we connect to the docker container postgresql_database
and execute the psql
command in the container, providing the username and the database name as parameters, asking for an interactive terminal (-it
).
When we run the command on the command line, we are connected to the database.
docker exec -it postgresql_database psql -U username database
psql (17.0 (Debian 17.0-1.pgdg120+1))
Type "help" for help.
database=# \dt
Did not find any relations.
database=# \quit
As we can see, there are no tables in the database yet. Let’s next use a migration tool to create a table to the database.
Database migrations
For database migrations, we use a tool called Flyway. Flyway is a database migration tool that allows us to manage database migrations using SQL files. Flyway is available as a Docker image, and we can add it to our project by adding a service to the compose.yaml
file. First, add a service called database-migrations
to the compose.yaml
file, copying the following configuration to it.
database-migrations:
image: flyway/flyway:10
env_file:
- project.env
depends_on:
- database
volumes:
- ./database-migrations:/flyway/sql
command: -connectRetries=60 -baselineOnMigrate=true migrate
The above configuration states that we have a service called database-migrations
that uses the image flyway/flyway:10
. The service uses the environment variables defined in the project.env
file, depends on the database
service, and mounts the folder database-migrations
to the folder /flyway/sql
in the container. The service is run with the command migrate
, which migrates the database to the latest version.
In the above configuration, we mapped a folder called database-migrations
to the folder /flyway/sql
in the container. This is where Flyway looks for the migration files. Create the folder database-migrations
to the root of the project, and create a file called V1__todos.sql
to the folder. Place the following create table statement to the file.
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
done BOOLEAN NOT NULL DEFAULT false
);
Flyway migration files are SQL files that have a versioning prefix that helps Flyway execute them in the correct order. The versioning prefix is of the form V<version>__<description>.sql
where <version>
is a number and <description>
is a description of the migration. Note that there are two underscores in the file name.
At this point, the compose.yaml
should look as follows.
services:
client:
build: client
restart: unless-stopped
volumes:
- ./client/src:/app/src
ports:
- 5173:5173
depends_on:
- server
database:
container_name: postgresql_database
image: postgres:17.0
restart: unless-stopped
env_file:
- project.env
database-migrations:
image: flyway/flyway:10
env_file:
- project.env
depends_on:
- database
volumes:
- ./database-migrations:/flyway/sql
command: -connectRetries=60 -baselineOnMigrate=true migrate
e2e-tests:
entrypoint: "/bin/true"
build: e2e-tests
network_mode: host
depends_on:
- client
volumes:
- ./e2e-tests/tests:/app/tests
server:
build: server
restart: unless-stopped
volumes:
- ./server:/app
ports:
- 8000:8000
depends_on:
- database
Next, we need to also add Flyway-specific environment variables to the project.env
file. Flyway expects three environment variables to be defined — FLYWAY_USER
, FLYWAY_PASSWORD
, and FLYWAY_URL
.
The variables are defined as follows. The first one is the username for the database, the second is the password for the database, and the third is a Java Database Connectivity (JDBC) URL for the database (Flyway is written in Java). The URL is of the form jdbc:postgresql://<host>:<port>/<database>
. In our case, the host is postgresql_database
, the port is 5432
, and the database is database
. The values for the environment variables are as follows.
FLYWAY_USER=username
FLYWAY_PASSWORD=password
FLYWAY_URL=jdbc:postgresql://postgresql_database:5432/database
At this point, the project.env
file should look as follows.
FLYWAY_USER=username
FLYWAY_PASSWORD=password
FLYWAY_URL=jdbc:postgresql://postgresql_database:5432/database
POSTGRES_USER=username
POSTGRES_PASSWORD=password
POSTGRES_DB=database
Now, when we run docker compose up --build
— having run docker compose down
beforehand, we see that the image is downloaded and that the service starts up. There are a few messages from the service, but the most important one is the one that states that the database migration has been successfully applied. The output contains lines similar tot he following.
database-migrations-1 | Database: jdbc:postgresql://postgresql_database:5432/database (PostgreSQL 17.0)
database-migrations-1 | Schema history table "public"."flyway_schema_history" does not exist yet
database-migrations-1 | Successfully validated 1 migration (execution time 00:00.027s)
database-migrations-1 | Creating Schema History table "public"."flyway_schema_history" ...
database-migrations-1 | Current version of schema "public": << Empty Schema >>
database-migrations-1 | Migrating schema "public" to version "1 - todos"
database-migrations-1 | Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.018s)
With the project running, we can also verify that the database has been created by connecting to the database. When we connect to the database and list the tables, we notice that there are two tables — a table called todos
that we created in the migration file and a table called flyway_schema_history
that flyway uses to keep track of the migrations.
docker exec -it postgresql_database psql -U username database
psql (17.0 (Debian 17.0-1.pgdg120+1))
Type "help" for help.
database=# \dt
List of relations
Schema | Name | Type | Owner
--------+-----------------------+-------+----------
public | flyway_schema_history | table | username
public | todos | table | username
(2 rows)
database=# \quit
The table flyway_schema_history
keeps track of the migrations. The table also has a column called checksum
, which is used to ensure that the migration files have not been modified. If the contents of the migration file change, the checksum changes, and the migration is not applied to the database. This is to ensure that the database schema is not accidentally modified.
Accessing database programmatically
When working with databases, we need a database client that takes care of the communication with the database. We use Postgres.js. To take it into use, modify deno.json
to match the following.
{
"imports": {
"@hono/hono": "jsr:@hono/hono@4.6.5",
"postgres": "https://deno.land/x/postgresjs@v3.4.4/mod.js"
}
}
Modify the project.env
to match the following.
POSTGRES_USER=username
POSTGRES_PASSWORD=password
POSTGRES_DB=database
FLYWAY_USER=username
FLYWAY_PASSWORD=password
FLYWAY_URL=jdbc:postgresql://postgresql_database:5432/database
PGUSER=username
PGPASSWORD=password
PGDATABASE=database
PGHOST=postgresql_database
PGPORT=5432
And modify the compose.yaml
configuration of the “server” service to match the following.
server:
build: server
restart: unless-stopped
volumes:
- ./server:/app
ports:
- 8000:8000
env_file:
- project.env
depends_on:
- database
With these changes, we can now access the database from the server-side application. Let’s modify the server-side application to read all the todos from the database and return them as a JSON object. Modify the app.js
to match the following.
import { Hono } from "@hono/hono";
import { cors } from "@hono/hono/cors";
import { logger } from "@hono/hono/logger";
import postgres from "postgres";
const app = new Hono();
const sql = postgres();
app.use("/*", cors());
app.use("/*", logger());
app.get("/", (c) => c.json({ message: "Hello world!" }));
app.get("/todos", async (c) => {
const todos = await sql`SELECT * FROM todos`;
return c.json(todos);
});
export default app;
Now, when we run docker compose up --build
, we see that the project starts up. In addition, we can access the todos from the database by making a request to the server.
curl localhost:8000/todos
[]%
As there are no todos in the database, the response is an empty array. If you wish, you may access the database through Docker and add a row using SQL. After that, making a query to the server should return with an array that contains the data that you added.