DEV Community

Cover image for Dockerizing Spring Boot application with database and Vite frontend
Casper Küthe
Casper Küthe

Posted on • Originally published at casperswebsites.com

Dockerizing Spring Boot application with database and Vite frontend

In this article I will show you how you can make a docker compose configuration for a fullstack application with Spring Boot API as backend, a vite frontend application and Postgres as database. We'll also setup a docker compose configuration for development.

Project layout

We will create a docker compose consisting of 3 services:

  • A backend service with Spring Boot (starter web)
  • A frontend service with a Vite project (anything that supports npm run build and produces a dist folder will work)
  • A database, we will use Postgres, but you can also use the database you prefer

We'll also create a development docker compose which only contains a database service.

As usual the entire project is available at my github.

Directory structure

I assume that the project is a monorepo with the following directory structure:

root/
├─ backend/
├─ devops/
├─ frontend/
Enter fullscreen mode Exit fullscreen mode

The devops folder is where we'll put all of our docker files.

Dev setup Spring Boot & Database

You can get the same setup as I use here with spring Initializr. The project uses the following dependencies:

  • Spring Web: for the API
  • Spring Data JPA: for handling java -> sql using Hibernate as ORM
  • PostgreSQL Driver: allows java to make a connection to Postgres
  • Spring Boot Actuator: adds stuff for production like metrics and application health
  • Docker Compose Support: we use this dependency so the connection to the database defined in the docker compose file will be configured automatically

The root of the project contains compose.yml file, go ahead and delete it we don't need it. Instead we'll make our own docker compose in devops/compose.yml and one for development devops/compose.dev.yml.

Development setup

In compose.dev.yml we'll define a single service for our Postgres database

name: 'spring-boot-dev'

services:
  db:
    container_name: 'spring-boot-db-dev'
    image: postgres
    restart: always
    ports:
      - '5432:5432'
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: spring
    volumes:
      - postgres_data:/var/lib/postgresql/data
volumes:
  postgres_data:
Enter fullscreen mode Exit fullscreen mode

This creates a Postgres docker container with a default user postgres, password password and database spring.

It also creates a new volume postgres_data so we can persist our data. If you need to throw away your database during development you can simple stop the app, delete the volume and restart your app.

Next we need to tell Spring Boot the location of our docker compose file and that we're using Postgres. To do this we can add the following properties in the application.properties file:

# We use Postgres
spring.datasource.driverClassName=org.postgresql.Driver
# Needed for Postgres to correctly create the database schema
spring.jpa.generate-ddl=true
# Specify the location of the docker file
spring.docker.compose.file=devops/compose.dev.yml
Enter fullscreen mode Exit fullscreen mode

At this point you should be able to start the Spring Boot app without any errors and confirm that a docker container for postgres is created using docker ps.

> docker ps
CONTAINER ID   IMAGE      COMMAND                  CREATED          STATUS          PORTS                    NAMES
f5e42353deee   postgres   "docker-entrypoint.s…"   30 seconds ago   Up 29 seconds   0.0.0.0:5432->5432/tcp   spring-boot-db-dev
Enter fullscreen mode Exit fullscreen mode

And we can also confirm that we created a new volume with docker volume ls

> docker volume ls
DRIVER    VOLUME NAME
local     spring-boot-dev_postgres_data
Enter fullscreen mode Exit fullscreen mode

Confirm database connection

Let's confirm that our database is working by creating a small TODO application.

backend.model.Todo.java

package com.example.backend.model;

import jakarta.persistence.*;

@Entity
public class Todo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    @Column(nullable = false)
    private String title;
    @Column(nullable = false)
    private Boolean completed;

    // getters and setters
    // ...
}
Enter fullscreen mode Exit fullscreen mode

backend.repository.TodoRepository.java

package com.example.backend.repository;

import com.example.backend.model.Todo;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TodoRepository extends JpaRepository<Todo, Integer> {}
Enter fullscreen mode Exit fullscreen mode

backend.controller.TodoController.java

package com.example.backend.controller;

import com.example.backend.model.Todo;
import com.example.backend.repository.TodoRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/api/todo")
public class TodoController {
    private final TodoRepository repository;

    public TodoController(TodoRepository repository) {
        this.repository = repository;
    }

    @GetMapping
    public ResponseEntity<List<Todo>> getTodos() {
        List<Todo> todos = repository.findAll();
        return new ResponseEntity<>(todos, HttpStatus.OK);
    }

    @PostMapping
    public ResponseEntity<Todo> createTodo(@RequestBody Todo todo) {
        Todo newTodo = repository.save(todo);
        return new ResponseEntity<>(newTodo, HttpStatus.CREATED);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteTodo(@PathVariable int id) {
        repository.deleteById(id);
    }

    @PutMapping("/complete/{id}")
    public ResponseEntity<Todo> completeTodo(@PathVariable int id) {
        Optional<Todo> todoOpt = repository.findById(id);
        if (todoOpt.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "entity not found");

        Todo todo = todoOpt.get();
        todo.setCompleted(true);
        todo = repository.save(todo);
        return new ResponseEntity<>(todo, HttpStatus.OK);
    }
}
Enter fullscreen mode Exit fullscreen mode

We can create a new Todo and check if it exists:

curl -d '{"title": "test", "completed": false}' \
-H "Content-Type: application/json" \
-X POST localhost:8080/api/todo
Enter fullscreen mode Exit fullscreen mode

And

curl localhost:8080/api/todo
Enter fullscreen mode Exit fullscreen mode

When we stop and restart the application we can see that the created todo still exists, thus the data is correctly persisted in the volume. Even when you delete the docker container

docker rm spring-boot-db
Enter fullscreen mode Exit fullscreen mode

A new container is created when we restart the application and our todo is still present.

Backend production setup

Let's start by creating a docker compose file for our production setup containing the database:

devops/compose.prod.yml

name: 'spring-boot-prod'

services:
  db:
    container_name: 'spring-boot-db'
    image: postgres
    restart: always
    ports:
      - '5432:5432'
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: spring
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
Enter fullscreen mode Exit fullscreen mode

We want to use a custom Dockerfile to build our backend application.

devops/backend.Dockerfile

FROM maven:3.9-amazoncorretto-21-alpine AS build-stage

WORKDIR /app

# copy source files
COPY pom.xml ./
COPY src ./src

# build the jar
RUN mvn package spring-boot:repackage

FROM amazoncorretto:21-alpine-jdk AS production-stage

# copy the build jar
COPY --from=build-stage /app/target/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]
Enter fullscreen mode Exit fullscreen mode

This docker file consists of 2 stages: one for building the application and for running the application.

During the build stage the pom.xml and source code is copied to the docker container and then packages it into a .jar using mvn package spring-boot:repackage.

We later copy the build jar file from our build stage into the environment that can run the application as app.jar.

Now we can add the backend application as service in our compose.prod.yml

backend:
  container_name: 'spring-boot-backend'
  build:
    context: ../backend
    dockerfile: ../devops/backend.Dockerfile
  restart: always
  ports:
    - '8080:8080'
  environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/spring
      SPRING_DATASOURCE_USERNAME: postgres
      SPRING_DATASOURCE_PASSWORD: password
      SPRING_JPA_GENERATE-DDL: true
  depends_on:
    - db
Enter fullscreen mode Exit fullscreen mode

Note that we need to provide some configuration that was previously in the application.properties again.

With the following bash script we can easily start and rebuild the docker compose containers.

devops/run.sh

#!/bin/bash

# stop any previously running containers
docker compose -f devops/compose.prod.yml down
# build the images
docker compose -f devops/compose.prod.yml build
# start the containers
docker compose -f devops/compose.prod.yml up -d
Enter fullscreen mode Exit fullscreen mode

If you are on windows you can copy the commands and run them yourself. We can verify that our containers are running with docker ps

> docker ps
CONTAINER ID   IMAGE                      COMMAND                  CREATED          STATUS          PORTS                    NAMES
374fa23580bc   spring-boot-prod-backend   "java -jar app.jar"      19 seconds ago   Up 18 seconds   0.0.0.0:8080->8080/tcp   spring-boot-backend
550b3449c9dd   postgres                   "docker-entrypoint.s…"   19 seconds ago   Up 18 seconds   0.0.0.0:5432->5432/tcp   spring-boot-db
Enter fullscreen mode Exit fullscreen mode

using .env files

At this moment we put the environment varables directly in the docker compose files. This is not secure and flexible. Instead we'll put all our environment variables and secrets in a .env file in the root of our project.

.env

# Postgres variables
DB_USER="postgres"
DB_PASSWORD="password"
DB_DATABASE="spring"
# Spring boot variables
SPRING_DATASOURCE_URL="jdbc:postgresql://db:5432/spring"
Enter fullscreen mode Exit fullscreen mode

And we can update the compose.prod.yml file to use our environment variables.

name: 'spring-boot-prod'

services:
  db:
    container_name: 'spring-boot-db'
    image: postgres
    restart: always
    ports:
      - '5432:5432'
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_DATABASE}
    volumes:
      - postgres_data:/var/lib/postgresql/data

  backend:
    container_name: 'spring-boot-backend'
    build:
      context: ../backend
      dockerfile: ../devops/backend.Dockerfile
    restart: always
    ports:
      - '8080:8080'
    environment:
        SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
        SPRING_DATASOURCE_USERNAME: ${DB_USER}
        SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
        SPRING_JPA_GENERATE-DDL: true
    depends_on:
      - db

volumes:
  postgres_data:
Enter fullscreen mode Exit fullscreen mode

We can specify what environment variables file we want to use with docker using the --env-file flag (more info here). Let's modify the run.sh script to use our .env file.

devops/run.sh

#!/bin/bash

# stop any previously running containers
docker compose --env-file .env -f devops/compose.prod.yml down
# build the images
docker compose --env-file .env -f devops/compose.prod.yml build
# start the containers
docker compose --env-file .env -f devops/compose.prod.yml up -d
Enter fullscreen mode Exit fullscreen mode

don't forget to add the .env file to your .gitignore so you don't leak your secret values in your github repository!

Frontend setup

I won't go into depth with the frontend setup. You can find the entire project at my github. Basically what I did is create a vue project with

npm create vue@latest frontend
Enter fullscreen mode Exit fullscreen mode

install the dependencies and let chat gpt generate a todo app that fetches data from our api.

img

CORS

Ofcors we need to add a CORS configuration to our app. We can do it by adding a @Configuration class in our spring boot application

backend.config.CorsConfig

package com.example.backend.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Value("${frontend.url}")
    private String frontendUrl;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins(frontendUrl)
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*");
    }
}
Enter fullscreen mode Exit fullscreen mode

We defined the url of our frontend as environment variable with the name frontend.url we need to update application.properties so the value is availble in dev mode and in our prod docker compose and .env file:

application.properties

frontend.url=http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

compose.prod.yml

# ...
backend:
  container_name: 'spring-boot-backend'
  build:
    context: ../backend
    dockerfile: ../devops/backend.Dockerfile
  restart: always
  ports:
    - '8080:8080'
  environment:
      SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
      SPRING_DATASOURCE_USERNAME: ${DB_USER}
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      SPRING_JPA_GENERATE-DDL: true
      FRONTEND_URL: ${FRONTEND_URL}
  depends_on:
    - db
# ...
Enter fullscreen mode Exit fullscreen mode

.env

FRONTEND_URL="http://localhost:5173"
Enter fullscreen mode Exit fullscreen mode

Likewise, we need to specify to the frontend application what the url of the api is.

For development we can define a .env file

frontend/.env.development

VITE_API_HOST="http://localhost:8080"
Enter fullscreen mode Exit fullscreen mode

And we can add the same line in our production .env file

frontend/.env.production

VITE_API_HOST="http://localhost:8080"
Enter fullscreen mode Exit fullscreen mode

Note

In a production setup both urls should be domain names e.g. https://my-app.com and https://api.my-app.com respectively.

Dockerizing the Vite app

Similairly, we create a Dockerfile for our frontend application. We'll use nginx to serve our files. This Dockerfile is copied from the Vue.js website. The documentation is for Vue2, but the build steps for Vue3 are the same.

devops/frontend.Dockerfile

# build stage
FROM node:lts-alpine AS build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# production stage
FROM nginx:stable-alpine AS production-stage

# copy our config
COPY nginx-config/nginx.conf /etc/nginx/conf.d/default.conf

# copy content
COPY --from=build-stage /app/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

Again we have 2 stages, a build stage and a production stage. In the build stage we simply copy the files and run npm run build. Then in the production stage we copy the dist folder /app/dist into the default nginx location /usr/share/nginx/html as specified in the Docker docs.

Adjusting NGINX config for SPA applications
Our Vue.js app is a Single Page Application. This means that we only have a single .html file. If a user goes to another page e.g. /about, nginx will look for about.html which won't exists. To fix this we add in our nginx configuration that it should return index.html if no appropiate file is found.

frontend/nginx-config/nginx.conf

server {
    listen 80;
    listen  [::]:80;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri /index.html;
    }

    error_page 404 /index.html;
}
Enter fullscreen mode Exit fullscreen mode

Adding the frontend to the dcker compose

We can now add the frontend as a service to the compose.prod.yml file

# ...
frontend:
    container_name: 'spring-boot-frontend'
    build:
      context: ../Frontend
      dockerfile: ../devops/frontend.Dockerfile
    image: nginx:latest
    restart: always
    ports:
      - '5173:80'
# ...
Enter fullscreen mode Exit fullscreen mode

In this config we run the frontend application on the port 5173 so we can run it on a local machine. In a server you will want to use 80:80, or use a reverse proxy with SSL. You could also add SSL support to the nginx config and copy the needed certifications, you will need to adjust frontend.Dockerfile.

If you run the devops/run.sh script (or rebuild and restart the docker compose) the app runs correctly

> docker ps
CONTAINER ID   IMAGE                      COMMAND                  CREATED         STATUS         PORTS                    NAMES
e75f4f93da2d   spring-boot-prod-backend   "java -jar app.jar"      2 minutes ago   Up 2 minutes   0.0.0.0:8080->8080/tcp   spring-boot-backend
2b44adf07d47   postgres                   "docker-entrypoint.s…"   2 minutes ago   Up 2 minutes   0.0.0.0:5432->5432/tcp   spring-boot-db
9d1e11e4b8e9   nginx:latest               "/docker-entrypoint.…"   2 minutes ago   Up 2 minutes   0.0.0.0:5173->80/tcp     spring-boot-frontend
Enter fullscreen mode Exit fullscreen mode

Dockerignore

One small tip that could save a lot of headaches: you can also add a .dockerignore file to ignore folders from copying into your docker container. For example in the frontend application we can add a the node_modules folder to the .dockerignore so they won't get copied in our build stage.

frontend/.dockerignore

node_modules
Enter fullscreen mode Exit fullscreen mode

This could be necessary if you have some data in your resources folder that you don't need in docker, or you can mount a volume to a path in the resources folder. This way you still have access to your resources folder, but they don't need to get copied to the docker container saving space.

For example if you want to mount the data folder in backend/src/main/resources you could add

volumes:
    - ../backend/src/main/resources/data:/app/data
Enter fullscreen mode Exit fullscreen mode

to the backend service in devops/compose.prod.yml. This will create a bind mount from backend/src/main/resources/data to /app/data.

Conclusion

In this article we created a docker compose configuration for a Spring Boot API backend application, a PostgreSQL database and a Vite (Vue.js) frontend.

The code for the entire project can be found here.

Top comments (0)