Integrating GitHub Actions and Docker for a Java Project

Written by

In today's fast-paced development ecosystem, continuous integration and deployment (CI/CD) play a vital role in maintaining agility and delivering high-quality software. The use of tools that seamlessly integrate into our development workflow can significantly reduce overhead and streamline processes.

Enter GitHub Actions and Docker—two powerful platforms that, when combined, can supercharge the development lifecycle of your Java project. This tutorial will explore the integration of GitHub Actions with Docker to automate essential tasks such as building, testing, and deploying Java applications.

In a landscape where speed and precision are the standards, workflow automation emerges as a time-saver, error-reducer, and collaboration enhancer. By the end of this tutorial, you will have a clear grasp of how to set up GitHub Actions and Docker for your Java project, and you will see the positive impact they can have on your development process.

Setting the stage

We will use a straightforward Java 11 project with Gradle, complete with tests and a connection to a MongoDB Atlas database. Firstly, we will create a GitHub repository for our project and make some configurations. 

Screenshot showing the creation and naming of a new GitHub repository.

To define your testing environment within your GitHub account, navigate to “Settings,” then select “Environments,” and create your environment as shown in the image below:

Screenshot illustrating how to create a new environment in GitHub's Settings section.

You will use a MongoDB database to test your application, which you can set up using MongoDB Atlas. When you create your cluster, it will look like this:

MongoDB Atlas displays a message confirming that the sample dataset was successfully loaded.

Add the network access configuration: 

Image of the Network Acess section in MongoDB Atlas.

Next, go to the “Database Access” section and generate a password to connect to your database: 

Visual guide that explains how to create the password in MongoDB Atlas.

Click on “Connect.” You will need the string connection shown in the image below:

A visual guide that indicates how to add the connection string in MongoDB Atlas.
@RestController
@RequestMapping("/api/v1/calculator")
public class CalculatorController {

@GetMapping("/add/{num3}")
public Double add(@RequestParam("num1") Double num1, @RequestParam("num2") Double num2,
@PathVariable("num3") Double num3){
return num1+num2;
}

@GetMapping("/sub/{num1}/{num2}")
public Double substract(@PathVariable("num1") Double num1, @PathVariable("num2") Double num2){
Double result = null;
if(num1>num2){
result = num1-num2;
}else{
result = num2-num1;
}
return result;
}

@PostMapping("/mul")
public ResponseEntity<Double> multiply(@RequestBody CalculatorDTO calculatorDTO){
Double result = null;
result = calculatorDTO.getNum1() * calculatorDTO.getNum2() * calculatorDTO.getNum3() * calculatorDTO.getNum4();
ResponseEntity<Double> responseEntity = new ResponseEntity<Double>(result, HttpStatus.OK);
return responseEntity;
}

}

Additionally, we have the respective test class: 

@ExtendWith(MockitoExtension.class)
public class CalculatorControllerTest {
@InjectMocks
private CalculatorController calculatorController;

static Double num1;
static Double num2;
static Double num3;


@BeforeAll
static void beforeAll(){
num1 = 3.5;
num2 = 3.5;
num3 = 3.5;
}

@Test
@DisplayName("Test Addition")
void testAddFunction_Success(){
Double result = calculatorController.add(num1, num2, num3);
assertEquals(7.0, result);
}

@Test
@DisplayName("Test Addition Failure Scenario")
void testAddFunction_Failure(){
Double result = calculatorController.add(num1 - 0.5, num2, num3);
Assertions.assertNotEquals(7.0, result);
}

@Test
@DisplayName("Test Substraction")
public void testSubFunction_num1_gt_num2(){
Double result = calculatorController.substract(num1+1, 2+num2);
assertEquals(1.0, result);
}

@Test
@DisplayName("Test Multiplication")
void testMultiply() {

CalculatorDTO calculatorDTO = new CalculatorDTO();
calculatorDTO.setNum1(num1);
calculatorDTO.setNum2(num2);
calculatorDTO.setNum3(num3);
calculatorDTO.setNum4(2.0);

ResponseEntity<Double> responseEntity = calculatorController.multiply(calculatorDTO);
assertEquals(85.75, responseEntity.getBody());
assertEquals(HttpStatus.OK.value(), responseEntity.getStatusCodeValue(), "Expecting the status as OK");

}
}

The application properties file has the next configuration:

MONGODB_USERNAME=${MONGODB_USERNAME_ENV}
MONGODB_PASSWORD=${MONGODB_PASSWORD_ENV}
MONGODB_CLUSTER_ADDRESS=${MONGODB_CLUSTER_ADDRESS_ENV}
MONGODB_DB_NAME=${MONGODB_DB_NAME_ENV}
server.port=3000

The ApplicationConfig class contains the definitions necessary to establish a connection to our database:

@Configuration
public class ApplicationConfig {
@Value("${MONGODB_USERNAME}")
private String dbUser;

@Value("${MONGODB_PASSWORD}")
private String dbPassword;

@Value("${MONGODB_CLUSTER_ADDRESS}")
private String clusterAddress;

@Value("${MONGODB_DB_NAME}")
private String dbName;


@Bean
public MongoClient mongoClient() {
try {
String encodedUser = URLEncoder.encode(dbUser, "UTF-8");
String encodedPassword = URLEncoder.encode(dbPassword, "UTF-8");

String uri = String.format("mongodb+srv://%s:%s@%s/%s",
encodedUser, encodedPassword, clusterAddress, dbName);
return MongoClients.create(uri);
} catch (Exception e) {
throw new RuntimeException("Error creating MongoClient", e);
}
}

@Bean
public MongoTemplate mongoTemplate() {
return new MongoTemplate(mongoClient(), dbName);
}
}

After this, you can create the secrets in the previously built testing environment, as shown in the below image; notice that you can specify the branch to use the environment secrets.

Screenshot of GitHub "Environments" section that highlights where to manage deployment branches and environment secrets.

Configuring GitHub actions workflow

Now, let's navigate to the workflow file. It should be located in the root of your project at this location: GitHub\workflows\{yourFileName}.yml. The CI workflow caches Gradle dependencies, grants execution permissions to the Gradle wrapper, and runs build and test commands. After testing, it initializes the server and provides MongoDB-related configurations. For security reasons, MongoDB Atlas credentials, such as the username and password, are retrieved from GitHub Secrets. Upon successful testing, a deployment step outputs MongoDB information and designates a port for deployment, typically set to 3000.

name: Java CI with Gradle

on:
push:
branches:
- master
- dev

env:
CACHE_KEY: gradle-deps
MONGODB_DB_NAME: gha-demo

jobs:
test:
environment: testing
runs-on: ubuntu-latest

env:
MONGODB_CONNECTION_PROTOCOL: mongodb+srv
MONGODB_CLUSTER_ADDRESS: cluster0.au2o0dy.mongodb.net
MONGODB_USERNAME: ${{ secrets.MONGODB_USERNAME }}
MONGODB_PASSWORD: ${{ secrets.MONGODB_PASSWORD }}

steps:
- name: Get Code
uses: actions/checkout@v3

- name: Cache Gradle dependencies
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ env.CACHE_KEY }}-${{ hashFiles('**/gradle.properties') }}

- name: Change wrapper permissions
run: chmod +x ./gradlew

- name: Build and Test with Gradle
run: ./gradlew build

- name: Start server (for future steps)
run: ./gradlew bootRun & sleep 10

- name: Output information
run: |
echo "MONGODB_DB_NAME: $MONGODB_DB_NAME"
echo "MONGODB_USERNAME: $MONGODB_USERNAME"
echo "${{ env.PORT }}"

deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Output deployment information
env:
PORT: 3000
run: |
echo "MONGODB_DB_NAME: $MONGODB_DB_NAME"
echo "MONGODB_USERNAME: $MONGODB_USERNAME"
echo "${{ env.PORT }}"

After making changes to your local Git repository and pushing them to the remote repository, navigate to the “Actions” tab to see your workflows. In the case of your most recent commit, you will see something like this:

Git repository screenshot

As you can see, the database connection was successful and the project ran without conflicts. If you do an update to fail a test and then push the commit, you will see something like this in the GitHub interface:

Visualization of the failure message in GitHub, displaying user information and additional details.
Visualization of the AssertionFailedError message in GitHub.

Integrating Docker for CI/CD

Finally, you will use a service container to prove the advantages of integrating Docker into your CI/CD pipeline; for that, you will create a local database instance to check that your application is working by modifying your workflow file as follows:

name: Java CI with Gradle

on:
push:
branches:
- master
- dev

env:
CACHE_KEY: gradle-deps
MONGODB_DB_NAME: gha-demo

jobs:
test:
environment: testing
runs-on: ubuntu-latest
env:
MONGODB_CONNECTION_PROTOCOL: mongodb
MONGODB_CLUSTER_ADDRESS: mongodb
MONGODB_USERNAME: root
MONGODB_PASSWORD: example
PORT: 8080
services:
mongodb:
image: mongo
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
steps:
- name: Get Code
uses: actions/checkout@v3

- name: Cache Gradle dependencies
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ env.CACHE_KEY }}-${{ hashFiles('**/gradle.properties') }}

- name: Change wrapper permissions
run: chmod +x ./gradlew

- name: Build and Test with Gradle
run: ./gradlew build

- name: Start server (for future steps)
run: ./gradlew bootRun & sleep 10

- name: Output information
run: |
echo "MONGODB_DB_NAME: $MONGODB_DB_NAME"
echo "MONGODB_USERNAME: $MONGODB_USERNAME"
echo "${{ env.PORT }}"

deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Output deployment information
env:
PORT: 3000
run: |
echo "MONGODB_DB_NAME: $MONGODB_DB_NAME"
echo "MONGODB_USERNAME: $MONGODB_USERNAME"
echo "${{ env.PORT }}"

You should notice that there are two relevant differences in the testing job:

  • In the first setup, the MongoDB connection used an SRV connection protocol and specific cluster addresses and credentials from GitHub secrets. 
  • The next configuration employed a simplified MongoDB connection, with the credentials hardcoded. This setup also includes a services section that configures a MongoDB service using the official image, with specified root credentials and a port mapping of 27017.

When you push the changes to the repository, the triggered workflow will have the new step shown below: 

Git repository screenshot that displays a new "Initialize containers" step.
Git repository screenshot where the MongoDB connection can be visualized.

The previous YAML configuration shows that service containers offer clear benefits for CI/CD workflows. First and foremost, they create a separate environment for dependencies, ensuring that applications use consistent, predetermined versions of services such as databases, regardless of where the CI/CD process runs. This results in more predictable and reproducible test outcomes.

Moreover, by defining services directly within the configuration, developers can easily define and control the environment, making it simpler for team members to understand the necessary dependencies. This also streamlines the setup, reducing the need for external configurations or scripts to initialize services.

In the provided YAML context, the MongoDB service container can be quickly set up with the desired settings, removing potential inconsistencies or complexities that might arise from manually configuring databases.

Conclusion

By following this guide, you have successfully established a robust CI pipeline for your Java project using GitHub Actions and Gradle.

  • You set up triggers to automatically start your CI process when code is pushed to the master or dev branches.
  • In the testing phase of your workflow, you connected your environment with a MongoDB database, saved time by caching Gradle dependencies, fixed file permissions, and smoothly built and tested your Java app.
  • Moving forward, you learned how to deploy your app and got insights into its environment.

Embracing this automation not only guarantees the consistency and reliability of your applications but also significantly reduces the need for manual intervention. As a result, it streamlines your development process, minimizing the risk of human errors and enabling you to allocate more time and resources to critical tasks such as innovation or feature development. Ultimately, this seamless integration of GitHub Actions and Gradle empowers you to focus on what truly matters—delivering high-quality software efficiently.

--------------------

If you're passionate about coding and eager to work on cutting-edge projects, we want to hear from you! Check out our current job openings and apply today.

Frequently Asked Questions