WL
Software Engineer
Wassim Lagnaoui

Tutorial 12: Docker and CI with Jenkins

Containerise the backend and frontend, compose the full stack, and automate builds and Docker Hub pushes with Jenkins.

← Back to Tutorials

The final step is packaging the application for deployment. This tutorial covers the Dockerfile that containerises the Spring Boot backend, the docker-compose.yml that runs the entire stack (database, backend, frontend) with a single command, and the Jenkins pipeline that automates building and pushing images to Docker Hub on every commit.


1. Backend Dockerfile

FROM openjdk:17-jdk-slim
VOLUME /tmp
COPY target/RestaurantOrder-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
EXPOSE 8080
  • openjdk:17-jdk-slim: the slim variant strips out tools not needed at runtime, reducing the image size significantly compared to the full JDK image.
  • VOLUME /tmp: Spring Boot uses /tmp for embedded Tomcat's working directory. Declaring it as a volume moves it to Docker's storage layer, improving performance on some hosts.
  • COPY target/...jar app.jar: assumes the JAR has already been built by mvn package before docker build runs. The Jenkins pipeline does this in the stage before building the image.
  • ENTRYPOINT vs CMD: ENTRYPOINT is used here so the container always runs the JAR. CMD can be overridden at runtime; ENTRYPOINT cannot (without --entrypoint).
  • EXPOSE 8080: documents the port the app listens on: it doesn't actually publish it. Port publishing happens in docker-compose.yml or the docker run command.

2. docker-compose.yml: the full stack

services:
  db:
    image: postgres:14
    container_name: restaurant_postgres
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "5432:5432"
    volumes:
      - db_data:/var/lib/postgresql/data  # persists data across container restarts

  backend:
    image: wassim4592/restaurant_backend:latest
    container_name: restaurant_backend
    depends_on:
      - db
    environment:
      SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
      SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
      SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
      JWT_SECRET: ${JWT_SECRET}
    ports:
      - "8080:8080"

  frontend:
    image: wassim4592/restaurant_frontend:latest
    container_name: restaurant_frontend
    depends_on:
      - backend
    ports:
      - "3000:80"   # React build served by nginx inside the container on port 80

volumes:
  db_data:
  • All secrets via environment variables: no credentials are in the compose file itself. A .env file (excluded from git) provides the values at runtime.
  • depends_on: controls start order: backend starts after db, frontend starts after backend. Note: this only waits for the container to start, not for the service inside to be healthy. A health check would be needed for a stricter guarantee.
  • Named volume db_data: data persists even if the db container is removed and recreated. Without this, every restart wipes the database.
  • 3000:80 for frontend: the React app is built as static files and served by nginx inside the container on port 80. The host maps 3000 to 80 so users hit localhost:3000.
  • The three services form a chain: requests enter through the frontend, which calls the backend API on port 8080, which reads/writes to PostgreSQL on port 5432.

3. Jenkinsfile: the CI pipeline

pipeline {
    agent any
    environment {
        DOCKERHUB_CREDENTIALS = credentials('dockerhub-credentials') // Jenkins credential ID
        BACKEND_IMAGE  = "wassim4592/restaurant_backend:latest"
        FRONTEND_IMAGE = "wassim4592/restaurant_frontend:latest"
    }
    stages {
        stage('Checkout') {
            steps {
                checkout scm   // pull the latest code from the configured SCM (GitHub)
            }
        }
        stage('Build Backend JAR') {
            steps {
                sh './mvnw clean package -DskipTests'  // compile + package, skip tests for speed
            }
        }
        stage('Build and Push Backend Docker Image') {
            steps {
                script {
                    docker.withRegistry('https://index.docker.io/v1/', DOCKERHUB_CREDENTIALS) {
                        def backendImage = docker.build(env.BACKEND_IMAGE, '.')
                        backendImage.push('latest')
                    }
                }
            }
        }
        stage('Build and Push Frontend Docker Image') {
            steps {
                script {
                    docker.withRegistry('https://index.docker.io/v1/', DOCKERHUB_CREDENTIALS) {
                        def frontendImage = docker.build(env.FRONTEND_IMAGE, 'frontend')
                        frontendImage.push('latest')
                    }
                }
            }
        }
    }
    post {
        always {
            cleanWs()  // delete workspace after every build to avoid stale artifacts
        }
    }
}
  • credentials('dockerhub-credentials'): Jenkins retrieves the Docker Hub username/password from its credential store: never hardcoded in the pipeline file.
  • -DskipTests: tests are skipped in this pipeline to keep the CI cycle fast. A dedicated test stage (or a separate pipeline) should run the full test suite.
  • docker.build(image, context): the second argument is the Docker build context. '.' for the backend (Dockerfile at root), 'frontend' for the React app (Dockerfile inside the frontend directory).
  • push('latest'): tags the image as :latest on Docker Hub. In production pipelines you'd also push a version tag (e.g., the Git commit SHA) to keep a history of deployable images.
  • post { always { cleanWs() } }: workspace cleanup runs even if a stage fails, preventing disk space from accumulating on the Jenkins agent.

4. Running the full stack locally

# 1. Build the backend JAR
./mvnw clean package -DskipTests

# 2. Create a .env file with your secrets
cp .env.example .env
# edit .env with your DB password, JWT secret, etc.

# 3. Start everything
docker compose up -d

# 4. Check it's running
docker compose ps

# 5. Stop and tear down
docker compose down       # keeps the db_data volume
docker compose down -v    # also deletes the volume (wipes the database)
  • -d runs containers in detached mode (background). Use docker compose logs -f to tail logs.
  • docker compose down -v is destructive: it deletes all named volumes including db_data. Useful to reset a dev environment; never run it in production.

Key Takeaways

  • Use openjdk:17-jdk-slim (not the full JDK image) to keep the backend image as lean as possible.
  • All secrets (DB password, JWT secret, Docker Hub credentials) live in environment variables: never in source-controlled files.
  • Named volumes make database data persistent across container restarts; without them, every compose down wipes your data.
  • depends_on controls start order but not readiness: add health checks if you need to wait for PostgreSQL to be fully ready before the backend starts.
  • Push a version tag alongside :latest in CI so you can roll back to a specific image if a deployment goes wrong.
  • cleanWs() in a post { always } block prevents Jenkins agents from running out of disk space after many builds.