Wednesday, December 10, 2025

Docker plus Docker Compose for Spring Boot app

 


Docker + Docker Compose for Spring Boot app

Kubernetes manifests (Deployment, Service, HorizontalPodAutoscaler, ConfigMap, Secret)

GitHub Actions CI/CD workflow (build, test, build Docker image, push to registry)

Android app updated to MVVM (Kotlin) with Retrofit integration and simple persistence

placeholders is the place where you must insert credentials (Docker registry, secrets). Copy/paste into your projects and adjust names/credentials.

Docker (Spring Boot)

Dockerfile

# Use a multi-stage build for a Spring 
Boot jar
FROM eclipse-temurin:17-jdk-jammy AS build
WORKDIR /app
COPY pom.xml mvnw ./
COPY .mvn .mvn
COPY src src
RUN ./mvnw -B -DskipTests package

FROM eclipse-temurin:17-jre-jammy
ARG JAR_FILE=target/*.jar
COPY --from=build /app/${JAR_FILE} 
/app/app.jar
EXPOSE 8080
ENTRYPOINT ["java","-Xms256m","
-Xmx512m","-jar","/app/app.jar"]

.dockerignore

target/
.vscode/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar

docker-compose.yml (app + mysql)

version: "3.8"
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: examplepassword
      MYSQL_DATABASE: profitlossdb
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql

  profitloss-app:
    build: .
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql
://mysql:3306/profitlossdb?useSSL=
false&serverTimezone=UTC
      SPRING_DATASOURCE_USERNAME: root
      SPRING_DATASOURCE_PASSWORD: 
examplepassword
    depends_on:
      - mysql
    ports:
      - "8080:8080"

volumes:
  mysql-data:

Kubernetes manifests

Assume namespace profitloss. Secrets contain DB password and Docker registry creds — replace placeholders.

k8s/namespace.yaml

apiVersion: v1
kind: Namespace
metadata:
  name: profitloss

k8s/secret.yaml (DB password & optional registry)

apiVersion: v1
kind: Secret
metadata:
  name: profitloss-secret
  namespace: profitloss
type: Opaque
stringData:
  DB_PASSWORD: "examplepassword"        
   # replace
  REGISTRY_USERNAME: "your-registry-user" 
# replace if pushing private image
  REGISTRY_PASSWORD: "registry-pass"      
# replace

k8s/configmap.yaml (app config)

apiVersion: v1
kind: ConfigMap
metadata:
  name: profitloss-config
  namespace: profitloss
data:
  SPRING_DATASOURCE_URL: jdbc:mysql:
//profitloss-mysql:3306/profitlossdb?
useSSL=false&serverTimezone=UTC
  SPRING_DATASOURCE_USERNAME: root
  SPRING_JPA_HIBERNATE_DDL_AUTO: update

k8s/mysql-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: profitloss-mysql
  namespace: profitloss
spec:
  replicas: 1
  selector:
    matchLabels:
      app: profitloss-mysql
  template:
    metadata:
      labels:
        app: profitloss-mysql
    spec:
      containers:
        - name: mysql
          image: mysql:8.0
          env:
            - name: MYSQL_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: profitloss-secret
                  key: DB_PASSWORD
            - name: MYSQL_DATABASE
              value: profitlossdb
          ports:
            - containerPort: 3306
          volumeMounts:
            - name: mysql-data
              mountPath: /var/lib/mysql
      volumes:
        - name: mysql-data
          persistentVolumeClaim:
            claimName: mysql-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mysql-pvc
  namespace: profitloss
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 5Gi

k8s/app-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: profitloss-app
  namespace: profitloss
spec:
  replicas: 2
  selector:
    matchLabels:
      app: profitloss-app
  template:
    metadata:
      labels:
        app: profitloss-app
    spec:
      containers:
        - name: profitloss-app
          image: your-registry/profitloss
-service:latest   # replace with built image
          imagePullPolicy: IfNotPresent
          env:
            - name: SPRING_DATASOURCE_URL
              valueFrom:
                configMapKeyRef:
                  name: profitloss-config
                  key: SPRING_DATASOURCE_URL
            - name: SPRING_DATASOURCE_USERNAME
              valueFrom:
                configMapKeyRef:
                  name: profitloss-config
                  key:
 SPRING_DATASOURCE_USERNAME
            - name: SPRING_DATASOURCE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: profitloss-secret
                  key: DB_PASSWORD
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: profitloss-service
  namespace: profitloss
spec:
  selector:
    app: profitloss-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
  type: ClusterIP

k8s/hpa.yaml (autoscale)

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: profitloss-hpa
  namespace: profitloss
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: profitloss-app
  minReplicas: 2
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60

Optional: add Ingress manifest depending on your cluster.

GitHub Actions — CI/CD

This workflow:

  • Runs tests
  • Builds jar
  • Builds Docker image
  • Logs into Docker registry (Docker Hub/GitHub Packages)
  • Pushes image
  • Optionally applies Kubernetes manifests via kubectl (if you configure KUBE_CONFIG)

Create .github/workflows/ci-cd.yml:

name: CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

env:
  IMAGE_NAME: your-registry/profitloss-service

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17

      - name: Build and run tests
        run: ./mvnw -B clean verify

  build-and-push:
    needs: build-test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Login to registry
        uses: docker/login-action@v2
        with:
          registry: docker.io
          username: ${{ secrets.
DOCKERHUB_USERNAME }}   # set in repo secrets
          password: ${{ secrets.
DOCKERHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE_NAME }}:latest

  deploy-k8s:
    needs: build-and-push
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup kubectl
        uses: azure/setup-kubectl@v3
        with:
          version: 'latest'

      - name: Configure kubectl
        run: |
          echo "${{ secrets.KUBE_CONFIG }}" 
> kubeconfig
          export KUBECONFIG=$PWD/kubeconfig

      - name: Update image in deployment
 (kubectl set image)
        run: |
          kubectl -n profitloss set 
image deployment/profitloss-app 
profitloss-app=${{ env.IMAGE_NAME }}
:latest || true
          kubectl -n profitloss
 rollout status deployment/
profitloss-app --timeout=120s || true

Secrets to add in repo settings

  • DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (or GitHub Packages token)
  • KUBE_CONFIG — base64 encoded kubeconfig or raw kubeconfig contents (use caution)

Android — MVVM architecture (Kotlin)

I’ll provide a minimal, clean MVVM structure with Retrofit + LiveData + ViewModel + Repository.

Gradle dependencies (app/build.gradle)

plugins {
    id 'com.android.application'
    id 'kotlin-android'
}

android {
    compileSdk 34
    defaultConfig {
        applicationId "com.example.
profitlossapp"
        minSdk 21
        targetSdk 34
        versionCode 1
        versionName "1.0"
    }
}

dependencies {
    implementation "org.jetbrains.
kotlin:kotlin-stdlib:1.9.0"
    implementation 'androidx.core:
core-ktx:1.12.0'
    implementation 'androidx.
appcompat:appcompat:1.6.1'
    implementation 'com.google.
android.material:material:1.9.0'
    implementation 'androidx.
lifecycle:lifecycle-livedata-ktx:2.6.2'
    implementation 'androidx.
lifecycle:lifecycle-viewmodel-ktx:2.6.2'
    implementation 'com.
squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.
squareup.retrofit2:converter-gson:2.9.0'
    implementation 
'org.jetbrains.kotlinx:
kotlinx-coroutines-android:1.7.3'
}

Network layer (Retrofit)

network/ProfitLossApi.kt

package com.example.profitlossapp.network

import com.example.
profitlossapp.model.CalculateRequest
import com.example.
profitlossapp.model.CalculateResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST

interface ProfitLossApi {
    @POST("/api/calculate")
    suspend fun calculate
(@Body req: CalculateRequest): 
Response<CalculateResponse>
}

network/RetrofitClient.kt

package com.example.profitlossapp.network

import retrofit2.Retrofit
import retrofit2.converter.
gson.GsonConverterFactory

object RetrofitClient {
    private const val BASE_URL = 
"http://10.0.2.2:8080" // emulator -> host

    val api: ProfitLossApi by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory
(GsonConverterFactory.create())
            .build()
            .create
(ProfitLossApi::class.java)
    }
}

Models

model/CalculateRequest.kt

package com.example.profitlossapp.model
data class CalculateRequest(val cp: 
Double, val sp: Double)

model/CalculateResponse.kt

package com.example.profitlossapp.model
data class CalculateResponse(val type:
 String, val amount: Double, 
val percent: Double, val id: Long)

Repository

repository/ProfitLossRepository.kt

package com.example.profitlossapp.repository

import com.example.profitlossapp.
model.CalculateRequest
import com.example.profitlossapp.
model.CalculateResponse
import com.example.profitlossapp.
network.RetrofitClient
import retrofit2.Response

class ProfitLossRepository {
    suspend fun calculate(req: 
CalculateRequest): Response<CalculateResponse> {
        return RetrofitClient.
api.calculate(req)
    }
}

ViewModel

ui/MainViewModel.kt

package com.example.profitlossapp.ui

import androidx.lifecycle.*
import com.example.profitlossapp.
model.CalculateRequest
import com.example.profitlossapp.
model.CalculateResponse
import com.example.profitlossapp.
repository.ProfitLossRepository
import kotlinx.coroutines.launch

class MainViewModel : ViewModel() {

    private val repo = ProfitLossRepository()

    private val _result = 
MutableLiveData<CalculateResponse?>()
    val result: 
LiveData<CalculateResponse?> = _result

    private val _error = 
MutableLiveData<String?>()
    val error: LiveData<String?> = _error

    fun calculate(cp: Double, sp: Double) {
        viewModelScope.launch {
            try {
                val response =
 repo.calculate(CalculateRequest(cp, sp))
                if (response.isSuccessful) {
                    _result.value = 
response.body()
                    _error.value = null
                } else {
                    _error.value =
 "Server error: ${response.code()}"
                }
            } catch (e: Exception) {
                _error.value = e.message
            }
        }
    }
}

UI (Activity)

ui/MainActivity.kt

package com.example.profitlossapp.ui

import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.
app.AppCompatActivity
import com.example.profitlossapp.R

class MainActivity : AppCompatActivity() {
    private val vm:
 MainViewModel by viewModels()

    override fun 
onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val etCP =
 findViewById<EditText>(R.id.etCP)
        val etSP = 
findViewById<EditText>(R.id.etSP)
        val btn =
 findViewById<Button>(R.id.btnCalculate)
        val tv =
 findViewById<TextView>(R.id.tvResult)

        btn.setOnClickListener {
            val cp =
 etCP.text.toString().toDoubleOrNull()
            val sp =
 etSP.text.toString().toDoubleOrNull()
            if (cp == 
null || sp == null) {
                tv.text = "Enter valid numbers"
                return@setOnClickListener
            }
            vm.calculate(cp, sp)
        }

        vm.result.observe(this) { resp ->
            resp?.let {
                tv.text = 
"Type: ${it.type}\nAmount:
 ${it.amount}\nPercent:
 ${it.percent}\nID: ${it.id}"
            }
        }

        vm.error.observe(this) { err ->
            err?.let { tv.text = "Error: $it" }
        }
    }
}

Layout (res/layout/activity_main.xml)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android=
"http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:padding="24dp"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText android:id="@+id/etCP" 
android:hint="Cost Price" 
android:inputType="numberDecimal" 
android:layout_width="match_parent" 
android:layout_height="wrap_content"/>
    <EditText android:id="@+id/etSP"
 android:hint="Selling Price" 
android:inputType="numberDecimal"
 android:layout_marginTop="12dp"
 android:layout_width="match_parent" 
android:layout_height="wrap_content"/>
    <Button android:id="@+id/btnCalculate" 
android:text="Calculate" 
android:layout_marginTop="16dp" 
android:layout_width="match_parent"
 android:layout_height="wrap_content"/>
    <TextView android:id="@+id/tvResult" 
android:textSize="16sp" 
android:layout_marginTop="18dp" 
android:layout_width="wrap_content" 
android:layout_height="wrap_content"/>
</LinearLayout>

Quick instructions & notes

  • Docker: docker build -t your-registry/profitloss-service:latest . then docker push ... (or use the GitHub Actions workflow).
  • Docker Compose: docker compose up --build
  • Kubernetes: kubectl apply -f k8s/namespace.yaml && kubectl apply -f k8s/secret.yaml && kubectl apply -f k8s/configmap.yaml && kubectl apply -f k8s/mysql-deployment.yaml && kubectl apply -f k8s/app-deployment.yaml && kubectl apply -f k8s/hpa.yaml
  • GitHub Actions: set repository secrets (DOCKERHUB_USERNAME, DOCKERHUB_TOKEN, KUBE_CONFIG).
  • Android MVVM: run emulator and ensure Spring Boot is reachable — use 10.0.2.2 for the emulator or your machine IP for a real device. Allow CORS on Spring Boot if calling from other origins (add @CrossOrigin to controller or CorsFilter).

How to Build a Simple, Secure VPN (WireGuard) — Explanation + Code

  How to Build a Simple, Secure VPN (WireGuard) — Explanation + Code Overview — what you’ll learn This article explains how a Virtual Priv...