Welcome to the core of modern development practices, where Docker Compose orchestrates a symphony for your fullstack app. As a dynamic Fullstack Software Engineer, you grasp the pivotal role containerization plays in shaping software development.
This blog is your guide to unlocking Docker Compose's potential in building a fullstack app with MySQL, Spring Boot, and React. Docker Compose acts as the linchpin, streamlining the encapsulation, and management of each component.
We'll navigate setting up a development environment within Docker containers, explore the relationship between database design and Dockerized Spring Boot backends, and witness the magic of containerized React frontends. At every step, Docker Compose ensures cohesion and consistency across the development lifecycle.
Whether you're refining containerization skills or demystifying Docker, this blog empowers both seasoned developers and curious enthusiasts. Join us as we unravel Docker Compose's potential, elevating your fullstack development experience.
Fasten your seatbelt, and let’s sail through the realms of Docker Compose-driven fullstack development. 🚢✨
What are containers and how docker comes into picture?
Containers are lightweight, portable, and self-sufficient units that encapsulate software and its dependencies. They provide a consistent and isolated environment for running applications, allowing developers to package an application along with its dependencies, libraries, and runtime into a single container image. Containers are designed to be easily deployable across various environments, ensuring consistency from development to production.
Docker is a platform that enables the creation, deployment, and management of containers. It utilizes containerization technology to package applications and their dependencies into containers. Docker provides a set of tools and a runtime environment to build, ship, and run containers efficiently.
The typical Docker workflow involves creating a Dockerfile, which defines the steps to build a container image, and then using Docker commands to build, tag, and run containers based on that image. Docker Hub is a registry service provided by Docker, where container images can be stored and shared publicly or privately.

What does docker compose do?
Docker Compose is a tool for defining and running multi-container Docker applications. It allows you to define a multi-container environment using a simple YAML file, often named docker-compose.yml, and then use a single command to start and run the entire application stack. Docker Compose is particularly useful for managing the orchestration of complex applications that require multiple services and containers to work together.

Here are some key aspects and functionalities of Docker Compose:
Declarative Configuration: Docker Compose uses a declarative YAML file to define the services, networks, and volumes required for your application. This file specifies how containers should be configured and how they should interact.
Service Definition: Each containerized component of your application is defined as a service in the docker-compose.yml file. Services can include information such as the base Docker image, container name, ports to expose, environment variables, and more.
Networking: Docker Compose automatically creates a network for your services, allowing containers within the same docker-compose.yml file to communicate with each other using service names as hostnames.
Volume Sharing: You can define volumes in the docker-compose.yml file, enabling data persistence and sharing between containers. This is useful for scenarios where you want to persist data beyond the lifecycle of a single container.
Environment Variables: Docker Compose allows you to set environment variables for each service, making it easy to configure different aspects of your application without modifying the container images.
Orchestration and Scaling: Docker Compose simplifies the process of orchestrating multiple containers. You can start, stop, and scale your entire application with a single command. This makes it convenient for development, testing, and even production environments.
Ease of Use: Docker Compose provides a user-friendly command-line interface, making it accessible to both developers and system administrators. Common operations like starting, stopping, and inspecting the status of services are straightforward.
Pre-requisites
Install Docker
Install Docker Compose if not already installed through the Docker installation above
>docker -v
Docker version 24.0.7, build afdd53b
>docker-compose -v
Docker Compose version v2.23.3-desktop.2
Java & Maven - The example in this blog uses Java v21 and Maven v3.9.5
Node - The example in this blog uses node v18
The project structure used for the example in this blog is as follows:-
docker-compose-demo
|
|____backend
|
|____frontend
Setup a spring application with docker configuration
Create a Spring project with appropriate dependencies like Spring Web, Spring Data JPA and MySQL Driver.

Insert your MySQL configuration in the application properties
spring.datasource.url=jdbc:mysql://localhost:3306/product
spring.datasource.username=product
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
}
@RestController
@RequestMapping("/api/products")
@CrossOrigin("*")
public class ProductController {
private final ProductService productService;
@Autowired
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public List<ResponseProductDTO> getAllProducts() {
return productService.getAllProducts();
}
@GetMapping("/{id}")
public ResponseProductDTO getProductById(@PathVariable Long id) {
return productService.getProductById(id);
}
@PostMapping
public ResponseProductDTO saveProduct(@RequestBody RequestProductDTO product) {
return productService.saveProduct(product);
}
@DeleteMapping("/{id}")
public void deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
}
}
The back-end should be running at http://localhost:8080
Setup a React application with docker configuration
Create a react app and install axios to call apis
npm create vite@latest .
npm install
npm install axios
npm run dev
The example in this blog uses a react project created by Vite, so we need to modify the vite.config.tsto run the app on fixed port 3000 in our machine as well as in the container. Ignore this step if you are not using vite.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000
},
preview: {
host: true,
port: 3000
}
})
Create a basic html table with Edit and Delete button for each row and add new product form below the table
import { useEffect, useState } from 'react';
import axios from 'axios';
type Product = {
id: number;
name: string;
price: number;
};
const App = () => {
const apiUrl = 'http://localhost:8080/api/products';
const [products, setProducts] = useState<Product[]>([]);
const [newProductName, setNewProductName] = useState('');
const [newProductPrice, setNewProductPrice] = useState('');
const [editProductId, setEditProductId] = useState<number | null>(null);
const [editProductName, setEditProductName] = useState('');
const [editProductPrice, setEditProductPrice] = useState('');
useEffect(() => {
fetchProducts();
}, []);
const fetchProducts = async () => {
try {
const response = await axios.get(apiUrl);
setProducts(response.data);
} catch (error) {
console.error('Error fetching products:', error);
}
};
const addProduct = async () => {
try {
const response = await axios.post(apiUrl, {
name: newProductName,
price: parseFloat(newProductPrice),
});
setProducts((prevProducts) => [...prevProducts, response.data]);
setNewProductName('');
setNewProductPrice('');
} catch (error) {
console.error('Error adding product:', error);
}
};
const deleteProduct = async (id: number) => {
try {
await axios.delete(`${apiUrl}/${id}`);
setProducts((prevProducts) => prevProducts.filter((product) => product.id !== id));
} catch (error) {
console.error('Error deleting product:', error);
}
};
const startEditingProduct = (id: number, name: string, price: number) => {
setEditProductId(id);
setEditProductName(name);
setEditProductPrice(price.toString());
};
const cancelEditingProduct = () => {
setEditProductId(null);
setEditProductName('');
setEditProductPrice('');
};
const updateProduct = async () => {
try {
await axios.put(`${apiUrl}/${editProductId}`, {
name: editProductName,
price: parseFloat(editProductPrice),
});
fetchProducts();
cancelEditingProduct();
} catch (error) {
console.error('Error updating product:', error);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center'}}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold', marginBottom: '1rem' }}>Product List</h1>
<table style={{ borderCollapse: 'collapse', width: '80%', margin: 'auto' }}>
<thead>
<tr style={{ borderBottom: '1px solid #ccc' }}>
<th style={{ padding: '8px', textAlign: 'left' }}>ID</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Name</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Price</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Action</th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id} style={{ borderBottom: '1px solid #ccc' }}>
<td style={{ padding: '8px', textAlign: 'left' }}>{product.id}</td>
{editProductId === product.id ? (
<>
<td style={{ padding: '8px', textAlign: 'left' }}>
<input
style={{ border: '1px solid #ccc', padding: '6px' }}
type="text"
value={editProductName}
onChange={(e) => setEditProductName(e.target.value)}
/>
</td>
<td style={{ padding: '8px', textAlign: 'left' }}>
<input
style={{ border: '1px solid #ccc', padding: '6px' }}
type="text"
value={editProductPrice}
onChange={(e) => setEditProductPrice(e.target.value)}
/>
</td>
</>
) : (
<>
<td style={{ padding: '8px', textAlign: 'left' }}>{product.name}</td>
<td style={{ padding: '8px', textAlign: 'left' }}>{product.price}</td>
</>
)}
<td style={{ padding: '8px', textAlign: 'left' }}>
{editProductId === product.id ? (
<>
<button
style={{ backgroundColor: '#4CAF50', color: 'white', padding: '6px', borderRadius: '4px', marginRight: '4px' }}
onClick={updateProduct}
>
Update
</button>
<button
style={{ backgroundColor: '#ccc', padding: '6px', borderRadius: '4px' }}
onClick={cancelEditingProduct}
>
Cancel
</button>
</>
) : (
<>
<button
style={{ backgroundColor: '#2196F3', color: 'white', padding: '6px', borderRadius: '4px', marginRight: '4px' }}
onClick={() =>
startEditingProduct(product.id, product.name, product.price)
}
>
Edit
</button>
<button
style={{ backgroundColor: '#f44336', color: 'white', padding: '6px', borderRadius: '4px' }}
onClick={() => deleteProduct(product.id)}
>
Delete
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
<div style={{ marginTop: '1rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Add Product</h2>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Name:
<input
style={{ border: '1px solid #ccc', padding: '6px' }}
type="text"
value={newProductName}
onChange={(e) => setNewProductName(e.target.value)}
/>
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Price:
<input
style={{ border: '1px solid #ccc', padding: '6px' }}
type="text"
value={newProductPrice}
onChange={(e) => setNewProductPrice(e.target.value)}
/>
</label>
<button
style={{ backgroundColor: '#2196F3', color: 'white', padding: '6px', borderRadius: '4px' }}
onClick={addProduct}
>
Add Product
</button>
</div>
</div>
);
};
export default App;
The front-end should be running at http://localhost:3000
Add some demo data to test the connection

Docker Compose Config
Create a Dockerfile in backend directory
FROM maven:3.9.5 AS build
WORKDIR /app
COPY . .
RUN mvn clean install
FROM openjdk:21
WORKDIR /app
COPY --from=build /app/target/*.jar server.jar
EXPOSE 8080
CMD ["java", "-jar", "server.jar"]
Create a Dockerfile in frontend directory
FROM node:18-alpine as BUILD
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
FROM node:18-alpine as PROD
WORKDIR /app
COPY --from=BUILD /app/dist/ /app/dist/
EXPOSE 3000
COPY package.json .
COPY vite.config.ts .
RUN npm install typescript
CMD [ "npm", "run", "preview" ]
Add a .dockerignore in the frontend directory to restrict node_modules directory to hold up space
node_modules/
We can update the application.properties in the backend to support dynamic environment variables now.
spring.datasource.url=${SPRING_DATASOURCE_URL}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
In the project source directory i.e. docker-compose-demo directory, we need to add a docker-compose.yml. The environment variables should be appropriately used in the docker-compose.yml and application.properties.
version: '3.8'
services:
database:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: product
MYSQL_USER: product
MYSQL_PASSWORD: password
volumes:
- mysql_data:/var/lib/mysql
server:
build:
context: ./backend
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://database:3306/product
SPRING_DATASOURCE_USERNAME: product
SPRING_DATASOURCE_PASSWORD: password
depends_on:
- database
client:
build:
context: ./frontend
ports:
- "3000:3000"
depends_on:
- server
volumes:
mysql_data:
Running the database, back-end and front-end containers in the docker network
Run the following command to create images of mysql, server and client services provided in docker-compose.yml, create and run the images in containers in a single network in detached mode i.e. run the containers in the background, detached from the terminal
docker-compose up -d
The command line should look something like this which shows the execution of the docker-compose.yml file by the configuration we provide by properties like ports, depends_on, etc.

We should be able to see the containers running in a single network in out Docker desktop app.

The backend and frontend servers should be running on the ports we assigned in the docker-compose.yml file.

We can use the following command to stop and remove the containers.
docker-compose down
If we restart our containers again by docker-compose up -d, we should have our inserted data from before since we provided a volume named mysql_data in docker-compose.yml for persisting MySQL data.
Resources
Github repository to the provided example - Docker Compose Demo
Docker documentation
Docker Compose documentation
Conclusion
In conclusion, this blog covered the fundamental concepts of containers and how Docker facilitates their creation, deployment, and management. It delved into the role of Docker Compose as a tool for orchestrating multi-container Docker applications, emphasizing its declarative configuration, service definition, networking, volume sharing, environment variable setup, and ease of use.
The blog provides streamlined steps for configuring a Spring backend and a React frontend application with Docker, along with Dockerfiles for building container images. The Docker Compose configuration file is introduced briefly, emphasizing service setups and volume usage for data persistence.
The concluding section briefly guides readers on running the entire application stack with Docker Compose, underscoring the importance of volumes in persisting data.
In essence, this blog serves as a quick reference for developers seeking practical insights into containerization using Docker and Docker Compose, offering a simplified guide for a multi-container application environment.