In this article, we will guide you through the process of setting up a robust Continuous Integration and Continuous Deployment (CI/CD) pipeline to automatically deploy a Spring Boot web application onto an Ubuntu Amazon EC2 instance. We achieve this by leveraging GitHub Actions to automate the build and packaging phases, and AWS CodeDeploy to handle the secure, reliable delivery and execution of the application on the target server. By the end of this practical, every code change pushed to the repository will result in a fully validated, updated application running live on your EC2 instance.
Table of Contents
Step #1: Local Setup and Repository Structure
We start by creating the root directory and the essential configuration folders.
# Create the root project directory
mkdir deploy-web-app-ec2
cd deploy-web-app-ec2
# Create the main configuration folders
mkdir .github
mkdir aws
mkdir cloudformation
mkdir spring-boot-hello-world-example
The aws folder contains the runtime scripts required by AWS CodeDeploy.
# Create the scripts directory for CodeDeploy hooks
mkdir aws/scripts
We will prepare the GitHub Actions Workflow DirectoryThe .github folder holds the pipeline definitions.
# Create the workflows directory for GitHub Actions
mkdir -p .github/workflows
Step #2: Application Code and Boilerplate Files
We now set up the Spring Boot application structure and add the core application files.
We create the minimal structure for a typical Spring Boot application.
# Navigate into the application folder
cd spring-boot-hello-world-example
# Create the standard Maven source directory
mkdir -p src/main/java/com/helloworld/controller
# Create the application configuration files
# These are typically generated by an IDE or project initializer
touch .classpath .project pom.xml
Create the HelloWorldController.java. This is the sample application logic.
File: spring-boot-hello-world-example/src/main/java/com/helloworld/controller/HelloWorldController.java
package com.helloworld.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloWorldController
{
@RequestMapping("/")
public String hello()
{
return "<h1> Congratulations. You have successfully deployed the sample Spring Boot Application. </h1>";
}
}
Explanation: A standard Spring Boot @RestController that maps the root URL (/) to return a simple confirmation string, which we will use for service validation.
@RestController: Marks the class as a Spring component that handles incoming web requests and automatically serializes the return value (the HTML string) into the response body.@RequestMapping("/"): Maps thehello()method to the root URL path (/).- Return Value: Provides a clear confirmation message in
<h1>tags, which will be used later to validate the deployment’s success.
Create the SpringBootHelloWorldExampleApplication.java (Main Class). This is the entry point for the Spring Boot application.
File: spring-boot-hello-world-example/src/main/java/com/helloworld/SpringBootHelloWorldExampleApplication.java
package com.helloworld;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
@SpringBootApplication
public class SpringBootHelloWorldExampleApplication extends SpringBootServletInitializer
{
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(SpringBootHelloWorldExampleApplication.class);
}
public static void main(String[] args)
{
SpringApplication.run(SpringBootHelloWorldExampleApplication.class, args);
}
}
Explanation:
@SpringBootApplication: Combines@Configuration,@EnableAutoConfiguration, and@ComponentScan.extends SpringBootServletInitializer: Required when packaging the application as a WAR file for deployment to an external servlet container (like Tomcat).configure(SpringApplicationBuilder application): The method that registers the main application class for the WAR deployment process.
Maven POM Configuration: This file defines the project’s dependencies, packaging, and build process, explicitly setting the artifact name to SpringBootHelloWorldExampleApplication.war.
File: spring-boot-hello-world-example/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.2.BUILD-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.springtest</groupId>
<artifactId>SpringBootHelloWorldExampleApplication</artifactId>
<version>1</version>
<name>SpringBootHelloWorldExampleApplication</name>
<description>Demo project for Spring Boot</description>
<packaging>war</packaging>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.1.RELEASE</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.2.3</version>
<configuration>
<warName>SpringBootHelloWorldExampleApplication</warName>
</configuration>
<executions>
<execution>
<id>default-war</id>
<phase>prepare-package</phase>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</repository>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
</pluginRepository>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>
Explanation:
<packaging>war</packaging>: Specifies that the build process should create a Web Application Archive (.war) instead of the default executable JAR.<java.version>1.8</java.version>: Explicitly sets the required Java version to 8. This must match the GitHub Action setup.maven-war-pluginconfiguration: Sets the output filename of the packaged artifact toSpringBootHelloWorldExampleApplication.war, which is critical for theappspec.ymlfile.
IDE Boilerplate Files: These are standard files generated by IDEs (like Eclipse) that configure the project structure and build paths.
File: spring-boot-hello-world-example/.classpath
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" path="target/generated-sources/annotations">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="ignore_optional_problems" value="true"/>
<attribute name="m2e-apt" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="ignore_optional_problems" value="true"/>
<attribute name="m2e-apt" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>
File: spring-boot-hello-world-example/.project
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>spring-boot-hello-world-example</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.wst.common.project.facet.core.builder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.wst.validation.validationbuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.springframework.ide.eclipse.core.springbuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.springframework.ide.eclipse.boot.validation.springbootbuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.springframework.ide.eclipse.core.springnature</nature>
<nature>org.eclipse.jem.workbench.JavaEMFNature</nature>
<nature>org.eclipse.wst.common.modulecore.ModuleCoreNature</nature>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
<nature>org.eclipse.wst.common.project.facet.core.nature</nature>
<nature>org.eclipse.wst.jsdt.core.jsNature</nature>
</natures>
<filteredResources>
<filter>
<id>1638392068018</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>
Step #3: Code Deploy Configuration Files
These files instruct AWS CodeDeploy on what to deploy and how to run it on the Ubuntu EC2 instance.
Create appspec.yml (Root). This specification defines the deployment payload and lifecycle hooks.
File: appspec.yml (Note: Create this file back in the root directory: cd ..)
version: 0.0
os: linux
files:
- source: /spring-boot-hello-world-example/target/spring-boot-hello-world-example-0.0.1-SNAPSHOT.jar
destination: /home/ubuntu/deployment/app/
- source: /aws/scripts/
destination: /home/ubuntu/deployment/app/scripts/
hooks:
BeforeInstall:
- location: aws/scripts/before-install.sh
timeout: 300
runas: root
ApplicationStop:
- location: aws/scripts/application-stop.sh
timeout: 300
runas: root
AfterInstall:
- location: aws/scripts/after-install.sh
timeout: 300
runas: root
ApplicationStart:
- location: aws/scripts/application-start.sh
timeout: 300
runas: root
ValidateService:
- location: aws/scripts/validate-service.sh
timeout: 300
runas: root
Explanation:
files: Maps the built WAR file (from the Maven build) and the CodeDeploy scripts to the target directory/home/ubuntu/deployment/app/on the EC2 instance.hooks: Defines the deployment lifecycle. Each hook calls a specific script to execute actions at that stage of the deployment process.
Create CodeDeploy Scripts (aws/scripts). We now create the five executable scripts required by appspec.yml.
# Navigate to the scripts folder
cd aws/scripts
File: aws/scripts/application-stop.sh (Hook: ApplicationStop)
#!/bin/bash
# Stop any existing process running on port 8080
PID=$(lsof -t -i:8080)
if [ -n "$PID" ]; then
kill -9 $PID
echo "Stopped existing process with PID: $PID"
fi
Explanation: Safely terminates the old application instance to prepare for the new deployment.
File: aws/scripts/before-install.sh (Hook: BeforeInstall)
#!/bin/bash
# Commands executed before the new files are copied.
echo "Running any prerequisite installations..."
# Here you would typically clean old logs or ensure target directories exist.
mkdir -p /home/ubuntu/deployment/app
Explanation: Used for pre-deployment setup, such as creating target directories or installing system dependencies.
File: aws/scripts/after-install.sh (Hook: AfterInstall)
#!/bin/bash
# Commands executed after files are copied, but before the application starts.
echo "Setting permissions on copied files..."
chmod +x /home/ubuntu/deployment/app/spring-boot-hello-world-example-0.0.1-SNAPSHOT.jar
Explanation: Post-copy setup, often used to set executable permissions on the application artifact.
File: aws/scripts/application-start.sh (Hook: ApplicationStart)
#!/bin/bash
# Start the new application version
echo "Starting new application..."
cd /home/ubuntu/deployment/app/
JAR_NAME="spring-boot-hello-world-example-0.0.1-SNAPSHOT.jar"
nohup java -jar $JAR_NAME > app.log 2>&1 &
echo "Application started."
Explanation: Starts the newly deployed JAR file using nohup to ensure it runs in the background.
File: aws/scripts/validate-service.sh (Hook: ValidateService)
#!/bin/bash
# Perform a health check on the newly started application
echo "Validating service status..."
sleep 15 # Wait for the app to initialize
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080)
if [ "$RESPONSE" -eq 200 ]; then
echo "Service is running (HTTP 200)."
exit 0
else
echo "Service validation failed (HTTP $RESPONSE)."
exit 1
fi
Explanation: The final check. It waits 15 seconds and uses curl to verify the application is responding, failing the deployment if the check fails.
Return to root directory
cd ../..
Step #4: GitHub Actions Workflow
This file defines the CI/CD pipeline responsible for building, packaging, and triggering CodeDeploy.
Create deploy.yml File: .github/workflows/deploy.yml
name: CI/CD Deployment to EC2
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'maven'
- name: Build Application (in spring-boot folder)
run: mvn clean package -DskipTests
working-directory: ./spring-boot-hello-world-example # Build inside the app folder
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Upload Artifact to S3
uses: aws-actions/s3-sync@v4
with:
args: --exclude '.git/*'
bucket: ${{ secrets.AWS_S3_BUCKET_NAME }}
file: . # Uploads all files, including the built JAR, appspec.yml, and scripts
- name: Trigger CodeDeploy
uses: aws-actions/aws-codedeploy-deploy@v1
with:
application-name: ${{ secrets.AWS_CODEDEPLOY_APPLICATION }}
deployment-group-name: ${{ secrets.AWS_CODEDEPLOY_GROUP }}
s3-location: ${{ secrets.AWS_S3_BUCKET_NAME }}/${{ github.sha }}.zip
Explanation: This workflow ensures the build occurs correctly inside the application folder. It then uses the GitHub secrets to authenticate with AWS, uploads the entire workspace to S3, and initiates the CodeDeploy process.
on: push: branches: - main: The workflow triggers automatically whenever code is pushed to themainbranch.Set up Java 1.8: Uses theactions/setup-java@v4action to install Java 8, matching the version specified in thepom.xml.Build Application: Executes the Maven commandmvn clean package -DskipTestsinside the application directory, producing theSpringBootHelloWorldExampleApplication.warartifact.Configure AWS Credentials: Authenticates the GitHub Action runner with AWS using secrets stored in the GitHub repository (AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION).Upload Artifact to S3: Usesaws-actions/s3-sync@v4to bundle the entire project folder (including the newly built WAR file andappspec.yml) into a single ZIP file and uploads it to the configured S3 bucket.Trigger CodeDeploy: Initiates an AWS CodeDeploy deployment using the uploaded S3 artifact. It identifies the artifact using the bucket name and the unique Git commit hash (github.sha).
Step #5: Execution and Conclusion
Final Commands (Local)
After creating all the files and setting up your AWS resources and GitHub secrets, you are ready to push the code and trigger the pipeline.
# Add permissions to scripts (Crucial step before pushing)
chmod +x aws/scripts/*.sh
# Initialize Git and push all files
git init
git add .
git commit -m "Complete spring boot CI/CD setup"
# Set up remote and push (assuming origin is set)
git push origin main
The push command triggers the GitHub Action, which builds your Spring Boot app and hands the artifact off to AWS CodeDeploy. CodeDeploy executes the scripts defined in appspec.yml on the Ubuntu EC2 instance in a precise order (ApplicationStop -> BeforeInstall -> file copy -> AfterInstall -> ApplicationStart -> ValidateService), ensuring a robust and automated deployment of your web application.
Conclusion:
This robust pipeline provides true continuous deployment. Every change pushed to the main branch is automatically built, packaged, deployed, and validated, ensuring quick and reliable updates to your Spring Boot application running on the Ubuntu EC2 instance.
Related Articles:
Top 40 GitHub Actions Interview Questions and Answers
Reference: