Connecting to a PostgreSQL Docker container using Node & Slonik
Reading time ~10 mins
So I recently started a new project (which I will totally see through to completion). In the course of reliving the joys of configuring a project from scratch, I reached the point where I was ready to hook up my web API to a database. It turned out to be a little more frustrating than I’d expected it to be, after all I’d done it once before about a year ago so of course it should be fresh in my mind. Sarcasm aside I’m writing this to hopefully solidify it in my brain and failing that at least have this as a reference.
Some context
Before jumping in I just wanted to add some context. I am approaching the architecture of this project in a micro-service-like way. The project is a single mono-repo broken into clients (UIs), services (APIs) and utils (Shareable code). I think the benefit to this is approach will be to keep different parts of the project isolated and individually deployable, however it could always be bundled as a single image (I think so anyway, I guess I’ll find out).
Below is a snapshot of the rough shape of the project with some files not shown as they’re mostly configuration:
clients
utils
services ↴
··service A ↴
····src ↴
······api ↴
········api
········routes
········controller
········service
······db
······config
······main
····tests ↴
······integration
····.env
····docker-compose.yml
····package.json
Docker Compose configuration file
So in keeping with the idea of isolation each service will have it’s own DB (again, potentially since each service could be configured to point to a single DB at deployment) I opted to use Docker Compose to easily grab a PostgreSQL image and spin it up when the service starts. Here’s a look at the docker-compose.yml
as I have it right now. I’ll walk through and point out things that might be of interest if you’re not familiar with using Docker Compose.
version: '3.9'
services:
db:
image: 'postgres:14.1-alpine'
container_name: service_a_db
restart: always
environment:
POSTGRES_USER: service_a
POSTGRES_PASSWORD: super_secret
ports:
- 5000:5432
Config breakdown
-
version
The version is optional since v1.27.0 of Docker Compose however I feel it doesn’t hurt to include it. -
services
This is a list of services you want to spin up using Docker. In my case I just want a single service, a DB, that service being the one I’ve nameddb
. You can call yours Jim if you prefer. -
image
This if the Docker image I want to create a container from. It’s an official image on DockerHub for PostgreSQL. I’ve used the version tag so I can be certain that as new versions of PostgreSQL are released I am still developing and testing against the same as what my production environment is using. alpine denotes that this is a minimal sized container. -
container_name
This is optional but it is handy to help find your container once it’s up and running. If you omit it you’ll be given a default name. To check this is set correctly you can run the following command;docker ps
.docker ps
shows you a list of containers. You should see something like this:CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 51f30726c405 postgres:14.1-alpine "docker-entrypoint.s…" 26 hours ago Up 9 hours 0.0.0.0:5000->5432/tcp service_a_db
-
restart
I’m honestly not sure why this should be set to always after reading the Docker Compose docs but it is set this way according to the Docker Hub example but I’ll go with what they suggest. -
environment
Theenvironment
allows for passing environment variables into the container.POSTGRES_PASSWORD
is required by the official PostgreSQL image. For security reasons there is noroot
user when the DB starts. For my needs I’ve included a specific user withPOSTGRES_USER
. This also has the effect of creating a database of the same name. If this variable is omitted thenpostgres
will be used instead. -
ports
The ports expose network ports within the container to an “outside world”. By default, PostgreSQL runs on port5432
. In the case above I’ve mapped5000
as each of my services will increment, so Service A’s API will be 8000 with the DB on 5000 while Service B will be 8001 & 5001. Not exposing these ports when I initially wrote the config lead to which you can read about in Troubleshooting.
Working with the DB service
Now that we’ve got our config we’re ready to get our DB started. To do this we can run the command docker-compose up
. This command will build the image, create a container and start it. You’ll get a bunch of output but ideally you’ll see a final log that looks something like the following:
service_a_db | 2021-12-31 10:07:50.025 UTC [1] LOG: database system is ready to accept connections
You can optionally run this in the background using the detach flag; -d
, e.g. docker-compose up -d
. While this will let you use your terminal you’ll not see the logs. At times though you might want to see logs and you can do this with the following command; docker logs [CONTAINER_NAME]
for a one off look or with an optional follow flag; -f
to give you realtime updates.
To stop the service and remove any data you can run docker-compose down
.
If you want to mess around with the DB running in the container there’s two ways I do this. The first way is to use Docker’s exec
command to call psql
. Using docker exec -it [CONTAINER_NAME] psql -U [POSTGRES_USER]
, i.e. docker exec -it service_a_db psql -U service_a
or you can use the DB’s connection string, i.e. docker exec -it service_a_db psql postgres://service_a:super_secret@localhost:5432/service_a
. This should then give you access to psql
tooling.
Another approach is to access the container’s shell and then access psql
with it. This offers you more access to the container environment beyond just psql
. To do this use docker exec -it /bin/bash
and then connect to the DB in the same way as above.
Connecting to DB in Node
Now that the service is running we can attempt to connect the Node API to the running instance. For this I use Slonik. First of all though we’ll want to add the DB’s connection string to the env variables for the API. I am using dotenv so I just need to edit my .env
file to include it.
DB_CONNECTION_STRING=postgres://service_a:super_secret@localhost:5000/service_a
Notice that we’re using the port that we set in docker-compose.yml
and not the one that PostgreSQL uses by default. This tripped me up a bit an I’ll cover it in troubleshooting. Once the env variable is added, I access it using a config.ts
which looks something like this:
import { MissingConnectionStringError } from './errors'
const { DB_CONNECTION_STRING: dbConnectionString } = process.env
if (!dbConnectionString) {
throw new MissingConnectionStringError()
}
export const config = {
dbConnectionString,
} as const
A couple of things to note here. I’ve created a custom error to be thrown if the env variable has not been loaded correctly. I then export a config
object with the connection string so it can be accessed more easily and mark the config
as const
to avoid any unexpected attempts to overwrite the values within it. Using as const
will mark each property of the object as readonly
.
Finally I have a db.ts
which is where we’ll hook up Slonik to our DB and export a pool
that will allow us to connect and query the DB. The pool can then be imported into our services to make queries against the DB.
import { createPool, type DatabasePool } from 'slonik'
import { config } from './config'
export const pool: DatabasePool = createPool(config.dbConnectionString)
Troubleshooting
Not exposing ports
The first time I did this I didn’t expose the ports in the Docker compose config and my connection string used 5432
which resulted in a red herring of an error message. I would get the following error stack in my API:
/…/node_modules/slonik/src/factories/createConnection.ts:128
throw new ConnectionError(error.message);
^
ConnectionError: password authentication failed for user "service_a"
at createConnection (/…/node_modules/slonik/src/factories/createConnection.ts:128:15)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
This lead my down the path of wondering whether I had configured the environment variables for PostgreSQL correctly but it was resolved by exposing the ports correctly.