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/
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:
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
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
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
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
// ...
}
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> {}
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);
}
}
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
And
curl localhost:8080/api/todo
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
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:
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"]
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
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
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
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"
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:
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
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
install the dependencies and let chat gpt generate a todo app that fetches data from our api.
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("*");
}
}
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
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
# ...
.env
FRONTEND_URL="http://localhost:5173"
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"
And we can add the same line in our production .env
file
frontend/.env.production
VITE_API_HOST="http://localhost:8080"
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;"]
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;
}
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'
# ...
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
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
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
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)