How to Develop a Full Stack Next.js, FastAPI, PostgreSQL App Using Docker

12.12.2021

This is a continuation of previous tutorials on how to build and deploy a full-stack Next.js, FastAPI, and PostgreSQL app.

The branch for this tutorial:

https://github.com/travisluong/nfp-boilerplate/tree/tutorial-3-how-to-develop-using-docker

The complete project:

https://github.com/travisluong/nfp-boilerplate

Docker

Docker is a tool that allows you to run your software in containers. A container is a process and filesystem that is isolated from the processes and filesystem of your host machine. The custom filesystem is provided by a container image. Using docker, you will be able to run your software in a consistent environment every time. This is particularly useful when you have to onboard many developers onto a project and you end up in a situation where it works on one machine, but not the other due to differences in the host machine. Docker alleviates this very common problem.

Setup

The first step is to install Docker Desktop. https://www.docker.com/

Once you have downloaded and installed it, you are ready for the next step.

Open up the nfp-boilerplate project in VSCode or whichever text editor you use.

Building and running containers

The images used in this tutorial can be found on Docker Hub. We are using the official python and node images.

Backend

Create Dockerfile in nfp-backend.

FROM python:3.9

WORKDIR /usr/src/app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD [ "uvicorn", "main:app", "--reload", "--host", "0.0.0.0" ]

In .env, you will want to change the localhost string in your DATABASE_URL to host.docker.internal. It should look something like this.

DATABASE_URL=postgresql://nfp_boilerplate_user:password@host.docker.internal/nfp_boilerplate_dev

This allows the app inside the container to connect to the PostgreSQL instance running on your host machine.

Build the image.

$ docker build -t nfp-backend .

The -t flag tags the images.

Run the container.

$ docker run -p 8000:8000 -it --rm --name nfp-backend-running nfp-backend
  • The -p flag maps the host port to the container port. HOST:CONTAINER
  • The -i and -t flags are commonly used in combination for terminal access. It is shortened to -it.
  • The --rm flag ensures container is automatically removed when it exists.
  • nfp-backend-running is the name of the running container.
  • nfp-backend is the name of the image.

Frontend

Create Dockerfile in nfp-frontend.

FROM node:16

WORKDIR /usr/src/app

COPY package.json ./
RUN npm install

COPY . .

CMD [ "npm", "run", "dev" ]

CD into nfp-frontend and run the build:

$ docker build -t nfp-frontend .

Run the container:

$ docker run -p 3000:3000 -it --rm --name nfp-frontend-running nfp-frontend

Bind Mounts

You’ve learned how to build and run a container, however, this isn’t the ideal way to develop because changes to your files on your host filesystem won’t be reflected in the container. To remedy this issue, you can use a bind mount.

$ docker run -p 8000:8000 -it --rm --name nfp-backend-running -v "$PWD":/usr/src/app nfp-backend

The -v "$PWD":/usr/src/app will map your current directory to the /usr/src/app directory in the container. Now, the changes you make will be reflected in the container.

For the frontend, you will have to create this .babelrc file:

{
    "presets": ["next/babel"]
}

Otherwise, you’ll get this error: error - Failed to load SWC binary, see more info here: https://nextjs.org/docs/messages/failed-loading-swc

The bind mount can now be used for the frontend:

$ docker run -p 3000:3000 -it --rm --name nfp-frontend-running -v "$PWD":/usr/src/app nfp-frontend

Docker Compose

These docker commands can get quite long and unwieldy to type out. Docker Compose allows you to write all of the configurations into a YAML file and run everything in a single command.

Create a docker-compose.yml in the project root.

version: "3.9"

services:
  backend:
    build: nfp-backend
    ports:
      - 8000:8000
    volumes:
      - ./nfp-backend:/usr/src/app
  frontend:
    build: nfp-frontend
    ports:
      - 3000:3000
    volumes:
      - ./nfp-frontend:/usr/src/app

Run docker compose:

$ docker compose up

With one command, you can build and run all of the containers at the same time.

Database container

Let’s add a Postgres container to our docker setup. Add the following to the docker-compose.yml under services.

  db:
    image: postgres:14
    restart: always
    environment:
      POSTGRES_USER: nfp_boilerplate_user
      POSTGRES_DB: nfp_boilerplate_dev
      POSTGRES_PASSWORD: password

Since we’re changing from using the database instance running on our host to the one running inside of the container, we’ll have to change our database host in .env and alembic.ini.

In .env:

DATABASE_URL=postgresql://nfp_boilerplate_user:password@db/nfp_boilerplate_dev

In alembic.ini:

sqlalchemy.url = postgresql://nfp_boilerplate_user:password@db/nfp_boilerplate_dev

Notice that the host has changed to db in both files.

Type ctrl+c to shut down the currently running process.

Then run docker compose again:

$ docker compose up

We will need to run the migrations again since this is a new DB.

Open a terminal into the backend container. First, start by checking the running docker processes.

$ docker ps

Then run this:

$ docker exec -it nfp-boilerplate_backend_1 bash

Note: Make sure the container name matches what was listed from the docker ps.

Inside of the container, run migrations:

$ alembic upgrade head

Then, exit the container:

$ exit

Open a terminal into the database container:

$ docker exec -it nfp-boilerplate_db_1 bash

Open a psql session:

$ psql -U nfp_boilerplate_user nfp_boilerplate_dev

Verify that the tables were created.

# \dt

Verify the app is still functioning in by going to http://localhost:3000/notes in your browser.

Docker networks

It is worth mentioning that Docker Compose manages the entire setup of the Docker network. All the services in your docker-compose file will be able to communicate with each other by their hostname, which is automatically set to their service name. For example backend, frontend, and db.

For example, inside of the frontend container, you can call the backend and vice versa:

$ curl backend:8000

This is why we were able to change the database host to db in the database settings.

Named volumes

If you’d like to persist your database data, you can use a named volume to do so.

Add the following to the docker-compose.yml:

    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

The final docker-compose.yml should look like this:

version: "3.9"

services:
  backend:
    build: nfp-backend
    ports:
      - 8000:8000
    volumes:
      - ./nfp-backend:/usr/src/app
  frontend:
    build: nfp-frontend
    ports:
      - 3000:3000
    volumes:
      - ./nfp-frontend:/usr/src/app
  db:
    image: postgres:14
    restart: always
    environment:
      POSTGRES_USER: nfp_boilerplate_user
      POSTGRES_DB: nfp_boilerplate_dev
      POSTGRES_PASSWORD: password
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Type ctrl+c to shut down the docker compose process.

Then run:

$ docker compose down

Then bring the containers back up with:

$ docker compose up

The data in the container should have persisted. Without the named volume, the data would be wiped forever if the image gets removed.

To see your volumes:

$ docker volume ls

Conclusion

Congratulations. You have set up a consistent development environment with Docker. If you find yourself running into the “It works on my machine” problem often, then consider taking a look at Docker. A small investment now might save you and your team wasted hours in the future.