Deploying a Spring Boot Application with PostgreSQL Using Docker, GitHub Actions, and AWS EC2
In this blog post, I’ll walk you through a complete guide on how to deploy a Spring Boot application with PostgreSQL as the database using Docker, GitHub Actions for CI/CD, and an AWS EC2 instance for hosting. This approach automates the entire process, from building the application to deploying it on a remote server.
Prerequisites
- Basic knowledge of Docker, Spring Boot, and GitHub Actions.
- An AWS account with access to an EC2 instance.
- A Docker Hub account.
- GitHub repository for your project.
Step 1: Setting Up the Spring Boot Application
We have a Spring Boot application configured to use PostgreSQL as its database. In the application.properties
file, we use environment variables to keep sensitive information like database credentials secure.
application.properties
# Use environment variables for database connection configuration
spring.datasource.url=${SPRING_DATASOURCE_URL}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
# Hibernate / JPA Configuration
spring.jpa.hibernate.ddl-auto=${SPRING_JPA_HIBERNATE_DDL_AUTO:update} # Default to 'update' if not provided
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
# Spring Profile
spring.profiles.active=${SPRING_PROFILES_ACTIVE:default} # Default to 'default' profile if not provided
server.port=${SERVER_PORT:8080}
In the configuration file, we rely on environment variables for the database URL, username, password, and other properties. This allows for flexibility when deploying to different environments, as we will inject these variables at runtime.
Step 2: Dockerizing the Application
The Dockerfile for this application is straightforward. It uses a lightweight Java 19 JDK base image and copies the pre-built JAR file into the Docker image.
Dockerfile
# Use a base image with Java
FROM eclipse-temurin:19-jdk-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy the jar file from the build directory to the container
COPY build/libs/*.jar app.jar
# Expose the application port
EXPOSE 8080
# Run the jar file
ENTRYPOINT ["java", "-jar", "app.jar"]
Explanation:
- Uses the
eclipse-temurin:19-jdk-alpine
image for a minimal Java runtime environment. - Copies the pre-built JAR file (created by Gradle) into the container.
- Exposes port
8080
for the application. - Runs the JAR file when the container starts.
Step 3: Using Docker Compose for Multi-Container Setup
The docker-compose.yml
file defines the multi-container setup with two services: the Spring Boot app and PostgreSQL.
docker-compose.yml
```yaml
version: '3.8'
services:
db:
image: postgres:latest
container_name: postgres-db
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- app-network
app:
image: tanmay031/user-management-app:latest
container_name: spring-boot-app
depends_on:
- db
environment:
SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
SPRING_JPA_HIBERNATE_DDL_AUTO: ${SPRING_JPA_HIBERNATE_DDL_AUTO}
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}
ports:
- "8080:8080"
networks:
- app-network
volumes:
postgres_data:
networks:
app-network:
Explanation:
- Defines two services:
db
(PostgreSQL) andapp
(Spring Boot application). - Uses environment variables for sensitive data like database credentials.
- Creates a Docker network (
app-network
) for communication between thedb
andapp
services.
In this step, we use GitHub Actions to automate the entire process of building the Spring Boot application, creating a Docker image, pushing it to Docker Hub, and deploying the application to an AWS EC2 instance. This is done through a CI/CD pipeline defined in the build-and-deploy.yml file.
name: Build and Deploy Spring Boot App
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
# Checkout the code
- name: Checkout code
uses: actions/checkout@v3
# Set up JDK 19
- name: Set up JDK 19
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '19'
# Grant execute permission for Gradle Wrapper
- name: Make Gradle Wrapper executable
run: chmod +x ./gradlew
# Cache Gradle packages
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
# Build the application with Gradle
- name: Build with Gradle (skip tests)
run: ./gradlew build -x test
# Set up Docker Buildx and Docker Compose in a single step
- name: Set up Docker
uses: docker/setup-buildx-action@v2
# Log in to Docker Hub
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# Build and push Docker image
- name: Build and push Docker image
run: |
docker buildx build --platform linux/amd64 -t tanmay031/user-management-app:latest --push .
deploy:
runs-on: ubuntu-latest
needs: build
steps:
# Checkout code
- name: Checkout code
uses: actions/checkout@v3
# Transfer docker-compose.yml to server
- name: Transfer docker-compose.yml to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: ./docker-compose.yml
target: /home/ec2-user/my-app/
# Deploy to Server via SSH
- name: Deploy to Server via SSH
uses: appleboy/ssh-action@v0.1.6
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
# Navigate to the app directory
cd /home/ec2-user/my-app
# Export environment variables for docker-compose
export POSTGRES_DB=${{ secrets.POSTGRES_DB }}
export POSTGRES_USER=${{ secrets.POSTGRES_USER }}
export POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
export SPRING_DATASOURCE_URL=${{ secrets.SPRING_DATASOURCE_URL }}
export SPRING_DATASOURCE_USERNAME=${{ secrets.SPRING_DATASOURCE_USERNAME }}
export SPRING_DATASOURCE_PASSWORD=${{ secrets.SPRING_DATASOURCE_PASSWORD }}
export SPRING_JPA_HIBERNATE_DDL_AUTO=update
export SPRING_PROFILES_ACTIVE=prod
# Bring down any existing Docker containers
docker-compose down
# Bring up Docker containers using exported environment variables
docker-compose up -d
Here’s a breakdown of each part of this step:
Triggering the Workflow
name: Build and Deploy Spring Boot App
on:
push:
branches:
- main
Trigger: This workflow is triggered whenever changes are pushed to the main
branch of the repository. You can change the branch to suit your project’s workflow (e.g., use a dev
or staging
branch).
Workflow Name: "Build and Deploy Spring Boot App"
is a human-readable name for this workflow, making it easy to identify in the GitHub Actions dashboard.
Job 1: Building the Application and Docker Image
This job is named build
and runs on an Ubuntu-based virtual machine provided by GitHub.
Setting Up the Build Job
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-latest
: Specifies the environment in which the job will run. ubuntu-latest
is a pre-configured virtual machine with all necessary tools for building and deploying the application.
Step 1: Checkout Code
- name: Checkout code
uses: actions/checkout@v3
Uses the actions/checkout
action to pull the latest code from the repository. This is essential as it provides the workflow with the codebase to build.
Step 2: Set Up Java Development Kit (JDK)
- name: Set up JDK 19
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '19'
This step uses actions/setup-java
to install JDK 19. The temurin
distribution is an open-source, production-ready JDK provided by the Eclipse Adoptium project.
Step 3: Grant Execute Permission for the Gradle Wrapper
- name: Make Gradle Wrapper executable
run: chmod +x ./gradlew
Why: The Gradle wrapper script (gradlew
) needs execution permissions to build the application. This command (chmod +x ./gradlew
) sets those permissions.
Step 4: Cache Gradle Packages
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
Purpose: To cache downloaded Gradle dependencies, speeding up subsequent builds.
Explanation:
path
: Specifies the directory (~/.gradle/caches
) where Gradle caches its downloaded dependencies.key
: Generates a unique key for the cache based on the OS and hash of Gradle files (e.g.,build.gradle
). If these files change, the cache will automatically refresh.restore-keys
: Allows using a partial match for the cache key if an exact match is not found.
Step 5: Build the Application with Gradle
- name: Build with Gradle (skip tests)
run: ./gradlew build -x test
This command compiles the Java application and packages it into a JAR file, skipping tests to save time (-x test
flag).
Output: The resulting JAR file will be placed in the build/libs
directory, ready for Docker to use in the next steps.
Step 6: Set Up Docker Buildx
- name: Set up Docker
uses: docker/setup-buildx-action@v2
Purpose: This action sets up Docker Buildx, a tool that allows building Docker images with more flexibility, including cross-platform builds (e.g., building for linux/amd64
architecture).
Step 7: Log in to Docker Hub
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
Uses the docker/login-action
to log in to Docker Hub using credentials stored in GitHub Secrets.
Security: The credentials (DOCKER_USERNAME
and DOCKER_PASSWORD
) are stored securely in the GitHub repository settings as secrets to avoid exposing sensitive information in the workflow file.
Step 8: Build and Push Docker Image
- name: Build and push Docker image
run: |
docker buildx build --platform linux/amd64 -t tanmay031/user-management-app:latest --push .
Explanation:
docker buildx build
: Uses Docker Buildx to create a Docker image.--platform linux/amd64
: Specifies the target platform for the Docker image, ensuring compatibility.-t tanmay031/user-management-app:latest
: Tags the Docker image with the nametanmay031/user-management-app
andlatest
version.--push
: After building the image, it automatically pushes it to Docker Hub.
Job 2: Deploying to the AWS EC2 Instance
The second job (deploy
) depends on the successful completion of the build
job (needs: build
).
Step 1: Checkout Code
- name: Checkout code
uses: actions/checkout@v3
Similar to the build job, this step checks out the repository to access the docker-compose.yml
file needed for deployment.
Step 2: Transfer docker-compose.yml
to the Server
- name: Transfer docker-compose.yml to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
source: ./docker-compose.yml
target: /home/ec2-user/my-app/
Uses the appleboy/scp-action
to securely transfer the docker-compose.yml
file to the EC2 instance.
Credentials: Uses SSH keys stored in GitHub Secrets to securely connect to the remote server (SERVER_HOST
, SERVER_USER
, and SERVER_SSH_KEY
).
Step 3: Deploy to Server via SSH
```yaml
- name: Deploy to Server via SSH
uses: appleboy/ssh-action@v0.1.6
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
# Navigate to the app directory
cd /home/ec2-user/my-app
# Export environment variables for docker-compose
export POSTGRES_DB=${{ secrets.POSTGRES_DB }}
export POSTGRES_USER=${{ secrets.POSTGRES_USER }}
export POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
export SPRING_DATASOURCE_URL=${{ secrets.SPRING_DATASOURCE_URL }}
export SPRING_DATASOURCE_USERNAME=${{ secrets.SPRING_DATASOURCE_USERNAME }}
export SPRING_DATASOURCE_PASSWORD=${{ secrets.SPRING_DATASOURCE_PASSWORD }}
export SPRING_JPA_HIBERNATE_DDL_AUTO=update
export SPRING_PROFILES_ACTIVE=prod
# Bring down any existing Docker containers
docker-compose down
# Bring up Docker containers using exported environment variables
docker-compose up -d
Explanation:
- SSH Connection: Uses the
appleboy/ssh-action
to connect to the EC2 instance using SSH keys stored in GitHub Secrets. - Environment Variables: Exports necessary environment variables (database credentials, Spring Boot configuration) for use in the
docker-compose.yml
file. - Deploy the Application:
docker-compose down
: Stops and removes any running containers to prepare for the new deployment.docker-compose up -d
: Starts the containers in detached mode, using the environment variables and the latest Docker image pushed to Docker Hub.
Summary of Step 4
- The
build
job compiles the Spring Boot application, builds a Docker image, and pushes it to Docker Hub. - The
deploy
job connects to the AWS EC2 instance, transfers thedocker-compose.yml
file, sets environment variables, and runsdocker-compose
to deploy the latest version of the application. - This automated pipeline ensures that every change pushed to the
main
branch is built, containerized, and deployed seamlessly to the remote server.
Conclusion
With this setup, you can quickly and reliably build, package, and deploy a Spring Boot application with a PostgreSQL database using Docker. GitHub Actions provides an automated CI/CD pipeline, while Docker and AWS EC2 allow for consistent and scalable deployments. By leveraging environment variables, you can keep sensitive information secure and easily adapt to different environments.