Setup the GitHub repository.
Setup the project using Spring Initializer with the following dependencies:
- Spring Devtools
- Spring Docker Compose
- Spring Web
Setup SSH keys for server access with:
ssh-keygen -t rsa -b 4096 -m pemAnd sent the public keys to Andreas Gottardi. Also added the public keys to our GitHub accounts.
Created this Controller class to test functionality:
Settings -> Actions -> Runners -> New self-hosted runner
SSH into the server and execute following commands:
mdkir -p ~/actions-runner
cd ~/actions-runnerThis creates a folder, which contains the GitHub Action runner at the root of server.
./config.sh \
--url https://github.com/OWNER/REPOSITORY \
--token YOUR_REGISTRATION_TOKENsudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh statusCreate following
.github/workflows/deploy.ymlin your repository.
A simplified pipeline file looks like e.g.:
name: Test self-hosted runner
on:
workflow_dispatch:
jobs:
test:
runs-on: self-hosted
steps:
- name: Show runner information
run: |
echo "Runner is working"
hostname
whoami
uname -aUsually the commands bellow are executed for building the application e.g: app.jar in the /build folder:
./gradlew buildThe build task also runs integration tests and therefore needs a running instance.
To create the application JAR before starting the instance, use:
./gradlew bootJarThis creates a jar File (app.jar) under the build folder at the applcation root.
But we need it to run in Docker environment. So the next steps are:
We created a Dockerfile at the root of our application folder.
This multi-stage Dockerfile builds a Java 21 Spring Boot application using eclipse-temurin:21-jdk-alpine, leveraging Docker layer and BuildKit cache mounts (/root/.gradle) to download Gradle dependencies before compiling the source code into an executable JAR. It then transitions to a lightweight eclipse-temurin:21-jre-alpine runtime stage that excludes build tools and source code to minimize the final image size and attack surface. Finally, it creates a dedicated non-root user (appuser), copies the compiled JAR into /app/app.jar, exposes port 8080, and configures the container to launch the application securely under that unprivileged user.
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
COPY gradlew .
COPY gradle gradle
COPY build.gradle settings.gradle ./
RUN chmod +x ./gradlew
RUN --mount=type=cache,target=/root/.gradle \
./gradlew dependencies --no-daemon
COPY src src
RUN --mount=type=cache,target=/root/.gradle \
./gradlew clean test bootJar --no-daemon
FROM eclipse-temurin:21-jre-alpine AS runner
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
EXPOSE 8080
COPY --from=builder --chown=appuser:appgroup /build/build/libs/*.jar app.jar
USER appuser
ENTRYPOINT ["java", "-jar", "app.jar"]This copies the pre-build application into the Docker image
This step builds the production Docker image from the root Dockerfile and tags it with both the newly generated semantic version (${IMAGE_NAME}:${IMAGE_TAG}) and latest.
- name: Build Docker
run: |
docker build -f Dockerfile \
-t ${IMAGE_NAME}:${IMAGE_TAG} \
-t ${IMAGE_NAME}:latest .This step builds an OCI container image using Podman from the root Containerfile and stores it locally under both the new semantic version (localhost/${IMAGE_NAME}:${IMAGE_TAG}) and latest tags.
- name: Build Podman
run: |
podman build -f Containerfile \
-t localhost/${IMAGE_NAME}:${IMAGE_TAG} \
-t localhost/${IMAGE_NAME}:latest .If the GitHub workflow is executed, it will create a docker image which contains our application on the server.
> curl http://127.0.0.1:8080/
Hello!⏎Integration tests run against a running application instance. They send real HTTP requests and verify the application with its active runtime configuration. The simplest test compares an endpoint response with a defined string.
Our original controller test example uses @WebMvcTest and MockMvc. It checks
the controller inside a test context:
package com.devops.devops2026.rest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(Controller.class)
class ControllerTest {
@Autowired
private MockMvc mvc;
@Test
@DisplayName("Should return Hello!")
void hello_ShouldReturnOkAndGreeting() throws Exception {
mvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(content().string("Hello!"));
}
@Test
@DisplayName("Should return provided Name")
void test_ShouldReturnProvidedName() throws Exception {
String testName = "Alice";
mvc.perform(get("/{name}", testName))
.andExpect(status().isOk())
.andExpect(content().string(testName));
}
}For integration testing, ControllerIntegrationTest sends real HTTP requests to
the running application. This is the implementation used by the project:
package com.devops.devops2026.rest;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ControllerIntegrationTest {
private void assertEndpoint(String path, String expected) throws Exception {
String baseUrl = System.getProperty("app.baseUrl", "http://localhost:8080");
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build().send(
HttpRequest.newBuilder(URI.create(baseUrl + path))
.timeout(Duration.ofSeconds(10)).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertEquals(expected, response.body());
}
@Test
void helloReturnsGreeting() throws Exception {
assertEndpoint("/", "Hello!");
}
@Test
void nameReturnsProvidedName() throws Exception {
assertEndpoint("/Alice", "Alice");
}
}Both examples check status 200 and the expected response strings. The controller
maps the name endpoint to /{name}, so the example uses /Alice.
Build an image with a unique tag, then start a test instance on port 18080:
IMAGE_NAME=team2/app
IMAGE_TAG="$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" .
docker run -d --name devops-integration \
-p 18080:8080 "${IMAGE_NAME}:${IMAGE_TAG}"Wait for the instance to become available before running tests:
ready=false
for attempt in $(seq 1 60); do
if curl --fail --silent --max-time 2 http://localhost:18080/ > /dev/null; then
ready=true
break
fi
sleep 2
done
if [ "$ready" != true ]; then
docker logs devops-integration
exit 1
fiRun only integration tests, or all checks including unit tests and the JaCoCo report:
APP_BASE_URL=http://localhost:18080 ./gradlew integrationTest --no-daemon
APP_BASE_URL=http://localhost:18080 ./gradlew check --no-daemonFor the deployed Docker instance use APP_BASE_URL=http://localhost:8080;
for the Podman instance use APP_BASE_URL=http://localhost:8090.
HTTP integration tests use the server's configuration; the test JVM's JaCoCo agent
does not measure code running in that external server.
A manual request should return Hello!:
curl --fail http://localhost:18080/After testing, remove the temporary instance:
docker rm -f devops-integrationAn image reference identifies the registry, repository and tag:
<registry>/<namespace>/<image>:<tag>
The registry hostname selects the destination, while the tag identifies a version. An image can have multiple tags. These act as aliases to the same image and layers and do not create additional copies of the image data.
The workflow currently generates semantic versions. To distinguish every workflow build, including reruns, a run number and attempt can be used instead:
- name: Set image tag
run: echo "IMAGE_TAG=run-${GITHUB_RUN_NUMBER}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_ENV"For manual builds, the timestamp and commit tag from the testing steps identifies the build. Replace the example registry and namespace below with your destination:
REGISTRY=registry.example.com
REMOTE_IMAGE="${REGISTRY}/team2/app"
docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "${REMOTE_IMAGE}:${IMAGE_TAG}"
docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "${REMOTE_IMAGE}:latest"The unique tag identifies the tested build. latest is a moving alias for the
most recently published successful build.
After all required tests succeed, log in and push the tested image:
docker login "$REGISTRY"
docker push "${REMOTE_IMAGE}:${IMAGE_TAG}"
docker push "${REMOTE_IMAGE}:latest"In CI, place authentication and push steps after every required test step and keep the default success condition so failed tests prevent publication. Push the image that was tested, without rebuilding it. The commands above are manual instructions; the current workflow does not yet publish images to a remote registry.
Reworked the deploy.yml file to run the following steps:
- build docker image
- build podman image
- run docker container on port 8080
- run podman service on port 8090
Removed the docker deployment from compose.yaml and separated the build and run steps for a fairer comparison between docker and podman.
The following simplified example shows the original deployment steps. See
.github/workflows/deploy.yml for the current versioned build and test workflow.
deploy.yml (simplified):
name: Build and Deploy locally
on:
push:
branches: ["main"]
workflow_dispatch:
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Docker
run: |
docker build -f Dockerfile -t app:latest .
- name: Build Podman
run: |
podman build -f Containerfile -t localhost/app:latest .
- name: Deploy Docker
run: |
docker rm -f devops-app 2>/dev/null || true
docker run -d \
--name devops-app \
-p 8080:8080 \
app:latest
- name: Deploy Podman
run: |
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
mkdir -p ~/.config/containers/systemd/
cp -av app.container ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user restart app.serviceThe deployment workflow waits for the Docker instance on port 8080 and the Podman
instance on port 8090. It runs check against Docker (unit tests, real HTTP
integration tests and JaCoCo XML coverage), then integration tests against Podman.
A failed readiness check or test prevents the analysis step from running.
Deployments are serialized because both use the same server ports.
After tests pass, Gradle uploads analysis and coverage to SonarQube Cloud and waits for the quality gate. A failed gate fails the workflow. Test and coverage reports are uploaded as workflow artifacts even when a preceding step fails.
- Import the GitHub repository into SonarQube Cloud.
- Verify that the organization is
hoe-coderand the project key ishoe-coder_devops26, matchingbuild.gradle. - Generate an analysis token with access to that project.
- In GitHub, open Settings > Secrets and variables > Actions and add the
repository secret
SONAR_TOKENwith that token. - If automatic analysis is enabled for this project, disable it before using CI-based analysis.
- Run the deployment workflow from the Actions tab or push to
main.
The self-hosted runner needs Docker, Podman, curl and access to Java downloads,
Gradle dependencies and https://sonarcloud.io. The workflow installs Java 21.
SONAR_HOST_URL is unnecessary because the Cloud URL is set in build.gradle.
To reproduce the test and analysis commands with the application already running:
APP_BASE_URL=http://localhost:8080 sh ./gradlew check --no-daemon
APP_BASE_URL=http://localhost:8090 sh ./gradlew integrationTest --no-daemon
# Set SONAR_TOKEN securely in your environment before running this command.
sh ./gradlew sonar --no-daemon -Dsonar.qualitygate.wait=trueCoverage describes application code executed in the test JVM. Measuring code in running containers requires separate JaCoCo instrumentation in those containers. The workflow currently deploys locally before testing; a failed quality gate does not roll back those deployments.