Dockerizing Your Application

This post links to: Building a Spring Boot CRUD App With Postgres from Scratch: The Complete Guide.

This guide follows on from Database Migrations with Liquibase in Spring Boot.

So far in the series, we've built a Spring Boot CRUD API from scratch, added security, documentation, database migrations, and tested everything locally. Now it's time to package our application using containerisation to prepare it for deployment to the cloud.

By the end of this post, you’ll have a production-ready Docker image for your application.

NOTE: If you want to publish your own container image and deploy it later in the series, fork the repository now and work from your fork. This allows you to publish images to your own GitHub Container Registry namespace and experiment freely without affecting the original project.

Traditional Deployment vs Virtual Machines vs Containers

Before cloud-native systems and containers became common, applications were usually deployed directly onto physical servers or manually configured virtual machines.

Traditional Physical Servers

So if you had a web application, you would deploy that application to your single physical server. The web application would use the underlying operating system, memory and CPU. For our example, our API would need Java and Postgres installed on the machine along with any other dependencies and config.

Not only would this usually be under-utilising the CPU and memory of the machine for a single application, scaling would be difficult as we would have to provision another server, with all the same setup. It would make things more difficult to keep in sync. You would also be tied to the underlying machine operating system.

It was also difficult to develop an application locally and be confident that it would then run the same on a server with a potentially different setup to your local machine.

Virtual Machines

Virtual machines (VMs) then became popular by improving isolation significantly. VMs would package the operating system, application dependencies, runtime software and the application itself altogether. The VM would also share memory and CPU from the underlying machine between multiple deployed VMs. A hypervisor was used to manage multiple VMs and allocate memory and CPU to each VM.

VMs solved some issues like replicating setup and creating more predictable configuration between multiple instances of an application, making it easier to scale. However, they were heavyweight needing a whole guest operating system to run, slower to startup and were more resource intensive. You would've run 3 operating systems just to run 3 applications.

Containers

Containers were designed to keep the isolation benefits of VMs while dramatically reducing overhead.

Containers package the application, dependencies and runtime libraries. But unlike VMs, containers share the host operating system kernel. So now you only need 1 operating system to run 3 applications.

Sharing the host OS makes the containers faster to startup and resource usage is much lower. Containers are also more consistent and highly portable. They package the runtime environment and rely on using a container runtime to run. So if you try running them locally on your machine that has a container runtime like Docker, they are highly likely to work on any machine that supports a container runtime.

Scaling is also made easier, as orchestration technologies like Kubernetes can quickly start and stop these lightweight containers.

Deployment Architecture Comparison

deployment-timeline.jpg

What Is Docker?

Docker is the most popular platform for building and running containers.

It allows us to:

A container image is essentially a blueprint or snapshot of your application.

Installing Docker

To follow along with the rest of this blog, if you haven't already, make sure to install Docker Desktop and the Docker Engine for your operating system.

To install Docker, you can follow the Docker section in the Java Spring Boot Prerequisites page.

Creating Our Dockerfile

A Dockerfile defines how our container image is built.

We can create a Dockerfile in the root of our project.

Dockerfile

And then add the following:

FROM eclipse-temurin:21-jdk

WORKDIR /app

COPY build/libs/crud.app-0.0.1-SNAPSHOT.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

Understanding the Dockerfile

The following defines the base image used to run our application. It already contains Java so we don't need to install it ourselves.

FROM eclipse-temurin:21-jdk

You can find lots of different base images from Docker Hub.

In the next section, the WORKDIR part sets the working directory to /app then the COPY part will copy our application jar that is built after running ./gradlew build into /app/app.jar

WORKDIR /app

COPY build/libs/crud.app-0.0.1-SNAPSHOT.jar app.jar

This next part will expose the Docker application to listen on port 8080:

EXPOSE 8080

Then this final part will execute when the container starts, running our app.jar using the java command.

ENTRYPOINT ["java", "-jar", "app.jar"]

Adding a .dockerignore file

We can add a .dockerignore file to the root of our project. This file will prevent any unnecessary files from being copied into the Docker build context. Reducing the image build time, size and avoid leaking any uneccessary files.

.dockerignore file contents:

.gradle
build
!build/libs/
.idea
.git

Building the Docker Image

First we need to build the application using:

./gradlew build

This will create the application jar in build folder:

build/libs/crud.app-0.0.1-SNAPSHOT.jar

We can then build the image using this command:

docker build -t our-api .

Viewing Images

Once you've built the image, you can view a list of local Docker images on your machine using this command:

docker image ls

You should see your built image outputted in the console like so:

REPOSITORY            TAG       IMAGE ID       CREATED         SIZE
our-api               latest    e25a9cc47cae   3 seconds ago   548MB
...                   ...       ...            ...             ...

Running the Container

Once our image has been created, we can run our application in Docker using the following command:

docker run -p 8080:8080 our-api

The -p flag will publish a container's port(s) to the host. So it connects the port of the Docker container inside the Docker container runtime to the port of our local machine. This allows access to the app in Docker at the URL http://localhost:8080.

However, when we run the container, you'll notice we get the following exception:

Caused by: java.net.ConnectException: Connection refused

This is due to the application running in Docker and not being able to find a connection to our database.

This happens because our application properties currently point to the following database connection url:

jdbc:postgresql://localhost:5432/test_db

Localhost changes meaning depending on where the application is running. On your machine, localhost means your machine. Inside Docker, localhost means the container. Since our database is not running inside the application container, we need to provide a Docker specific database host using environment variables.

Using Environment Variables

So to fix this issue, we can pass through environment variables, using the -e flag, at container runtime like so:

docker run \
  -p 8080:8080 \
  -e SPRING_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/test_db \
  our-api

host.docker.internal is a special DNS name provided by Docker. It allows the Docker container inside it's own network to reach the network on your local machine.

So something like this:

Container
↓
host.docker.internal (resolves to a dynamic IP address on your machine)
↓
Your local machine
↓
PostgreSQL on localhost:5432

We should now be able to access our API at http://localhost:8080/api/users. You'll need to provide the username admin and password supersecure to access the endpoint via basic auth.

Stopping Containers

We can also stop the container by listing all containers:

docker ps -a

and then running the stop command, replacing the <container-id> with the container you'd like to stop:

docker stop <container-id>

Image Tags and Versioning

A best practice when creating Docker images is to tag and version images. This helps track releases, allows rolling back of deployments and avoids accidental upgrades.

We can tag our image like so:

docker build -t our-api:v1 .

When viewing our images, you can now see our two images, the latest image and the v1 image:

REPOSITORY            TAG       IMAGE ID       CREATED          SIZE
our-api               latest    e25a9cc47cae   20 minutes ago   548MB
our-api               v1        e25a9cc47cae   20 minutes ago   548MB
...                   ...       ...            ...              ...

Then we can run our versioned image instead:

docker run \
  -p 8080:8080 \
  -e SPRING_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/test_db \
  our-api:v1

This makes it easier to make sure we are running the correct changes to our image.

Publishing Images (Optional)

NOTE: If you plan to follow along with the deployment blog which is next in this series, make sure you've forked this repo and then publish your image to GHCR. Otherwise, you can continue using the Docker image locally and skip this step and the next blog post.

The final part of Dockerizing our application to get it ready for deployment, is publishing our container images to some sort of container registry. A container registry is just storage for our container images. They are similar to Git repositories, but for container images. Popular registries include: Docker Hub, GitHub Container Registry (GHCR) and Amazon ECR just to name a few.

Publishing our images to a container registry will allow us to store our images, track versions and pull down images to run in various environments. This comes in handy when running our app using an orchestration technology like Kubernetes.

Using GHCR

Because our project resides in GitHub, we can use public packages in GitHub Container Registry (GHCR).

As mentioned previously, ensure you've forked this repo, otherwise you won't have access to publish packages to the repository.

Logging Into GHCR

To publish to GHCR, we first need to log in to give Docker access to push and pull from GHCR.

For your forked repo, you'll need to create a personal access token with the following permissions:

write:packages
read:packages

NOTE: make sure to replace YOUR_GITHUB_TOKEN and YOUR_GITHUB_USERNAME where applicable for the following commands in the terminal.

You can then run the following command to log into GHCR through Docker:

echo YOUR_GITHUB_TOKEN | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin

You should see the following in the terminal on successful login:

Login Succeeded

Tagging the Image for GHCR

Next we need to tag the image for GHCR. We can do that with the following command:

docker tag our-api:v1 ghcr.io/YOUR_GITHUB_USERNAME/our-api:v1

NOTE: make sure to run the Image Tags and Versioning step beforehand to get the our-api:v1 image.

You can view that the tag was created by running:

docker image ls

You should see the GHCR tagged image like so:

REPOSITORY                         TAG       IMAGE ID       CREATED         SIZE
our-api                            v1        4639a1744235   2 minutes ago   548MB
ghcr.io/full-bearded-dev/our-api   v1        4639a1744235   2 minutes ago   548MB
...                                ...       ...            ...             ...

Pushing the Image to GHCR

We now need to push the image to GHCR using the following command:

docker push ghcr.io/YOUR_GITHUB_USERNAME/our-api:v1

Once pushed, you should be able to find your new package in your GitHub account under: https://github.com/YOUR_GITHUB_USERNAME?tab=packages.

You can view the repo example here at https://github.com/full-bearded-dev?tab=packages.

By default, the package will be private.

Pulling and Running the Published Image

The last step is to actually pull and run the published image.

We can do that by pulling the image:

docker pull ghcr.io/YOUR_GITHUB_USERNAME/our-api:v1

And the running the image like so:

docker run \
  -p 8080:8080 \
  -e SPRING_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/test_db \
  ghcr.io/YOUR_GITHUB_USERNAME/our-api:v1

There we have it, we can now publish our container images to GHCR and pull the image down to run anywhere. In a production setup, the publishing of images would probably be done in a CI/CD pipeline using technologies like Jenkins, GitHub actions or CircleCi etc. These technologies would run other jobs like tests, code styling and formatting and code analysis before pushing the images to a container registry. However, for the purpose of this tutorial, we will stick to manually uploading the images.

What's Next?

So to recap we learnt:

GitHub Example

Stay tuned for the final blog in the series about deploying our application!