Deploy a web App using GitHub Actions and Amazon EC2

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.

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 the hello() 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-plugin configuration: Sets the output filename of the packaged artifact to SpringBootHelloWorldExampleApplication.war, which is critical for the appspec.yml file.

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 the main branch.
  • Set up Java 1.8: Uses the actions/setup-java@v4 action to install Java 8, matching the version specified in the pom.xml.
  • Build Application: Executes the Maven command mvn clean package -DskipTests inside the application directory, producing the SpringBootHelloWorldExampleApplication.war artifact.
  • 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: Uses aws-actions/s3-sync@v4 to bundle the entire project folder (including the newly built WAR file and appspec.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:

GitHub Actions official documentation

Prasad Hole

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Share via
Copy link
Powered by Social Snap