After containerizing applications with Docker (Project 3) and understanding the manual CI/CD workflow, the next step was obvious: automate it. That's where Jenkins comes in.

But here's what I didn't expect: automating one pipeline is straightforward. Automating ten nearly identical pipelines? That's where things get messy fast. And that's exactly the problem Jenkins Shared Libraries solve.

This project took me from writing basic Jenkinsfiles to building reusable pipeline code that can be shared across multiple projects. I learned why enterprises don't just copy-paste pipeline logic, how Groovy classes work differently inside Jenkins, and why the script object is simultaneously confusing and essential.

It took about 8 hours over two days. And I finally understand how companies manage hundreds of CI/CD pipelines without losing their minds.

The Problem (Or: Why Copy-Paste Doesn't Scale)

The scenario:

Imagine you're working on a microservices application with 10 different services:

All of them are Java Maven applications. All of them need Jenkins pipelines. And 90% of the pipeline logic is identical:

  1. Build JAR with Maven
  2. Build Docker image
  3. Login to Docker registry
  4. Push image to registry

Without Jenkins Shared Library, you'd have:

What happens when something changes?

This is a maintenance nightmare. And it gets worse with every new microservice you add.

The Solution: Extract the Logic

Jenkins Shared Library is a separate Git repository containing reusable Groovy code. Instead of copying the same pipeline logic into every project, you write it once and reference it from all your Jenkinsfiles.

The concept:

jenkins-shared-library (Git repo)
├── buildJar()
├── buildDockerImage()
├── dockerLogin()
└── dockerPush()

microservice-1 → calls buildJar(), buildDockerImage()...
microservice-2 → calls buildJar(), buildDockerImage()...
microservice-3 → calls buildJar(), buildDockerImage()...

One source of truth. Ten projects using it. Change once, affect everywhere.

Part 0: The Jenkins Journey So Far

Before diving into Shared Libraries, let me back up and explain how I got here.

Jenkins Setup

I'm running Jenkins on a DigitalOcean Ubuntu 22.04 droplet as a Docker container. The setup process was:

  1. Create a $48/month droplet (4GB RAM, 4 vCPUs, 160GB storage)
  2. Install Docker on Ubuntu
  3. Run Jenkins: docker run -d -p 8080:8080 -p 50000:50000 -v jenkins_home:/var/jenkins_home jenkins/jenkins:lts
  4. Initialize Jenkins and install plugins (Pipeline, Git, Docker)
  5. Configure build tools (Maven 3.9)

The tricky part: Making Docker available inside Jenkins.

Since Jenkins is running in a Docker container, and I need Jenkins to build Docker images, I had to mount the Docker socket from the host:

docker run -d -p 8080:8080 -p 50000:50000 \
  -v jenkins_home:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \
  jenkins/jenkins:lts

This lets Jenkins use the host's Docker daemon (Docker-in-Docker without actually nesting containers).

But there's a catch: Mounting the socket alone doesn't give you the docker command inside the Jenkins container. You need to install Docker CLI.

I had to access the running Jenkins container and install Docker:

# Get the container ID
docker ps

# Access the container as root
docker exec -u root -it <container-id> bash

# Inside the container:
apt-get update
apt-get install -y docker.io

# Verify
docker --version

Now Jenkins can execute docker build, docker push, etc. in pipeline steps.

Jenkins Plugin and Tool Configuration

Plugins configured:

Then I configured build tools in Manage Jenkins → Tools:

Maven configuration:

This is why my Jenkinsfile can reference:

tools {
    maven 'maven-3.9'
}

Git configuration:

Credentials setup — GitLab:

Credentials setup — Docker Hub:

Important: Later when I reference these in Groovy code, I use the UUID (the ID field), not the description or username.

The Progression I Actually Took

I didn't start with Shared Libraries. I went through multiple stages of learning Jenkins:

Stage 1: Freestyle Jobs

I started by clicking through the Jenkins UI. Everything was manual: New Item → Freestyle project → configure Git → add shell build steps. This is how I got the first builds working, but it was all UI clicks, no version control.

My actual Freestyle job build steps:

chmod +x freestyle-build.sh
./freestyle-build.sh

The shell script contained the Docker build and push logic. Later, this exact logic would move into the shared library as buildImage() and dockerPush() functions.

Build step 2 was "Invoke top-level Maven targets" with goals: clean package. Later, I simplified to just mvn package—the clean wasn't necessary since Jenkins builds in fresh workspaces.

Stage 2: Pipeline Jobs with Jenkinsfile

Then I learned about Pipeline jobs. Now my pipeline was code:

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                sh 'mvn package'
                sh 'docker build -t my-app .'
            }
        }
    }
}

Better! The pipeline is version controlled. But everything was in one massive Jenkinsfile.

Stage 3: External Groovy Scripts

Next, I learned to extract logic into external Groovy scripts. I created script.groovy:

def buildJar() {
    echo "Building JAR..."
    sh 'mvn package'
}

def deployApp() {
    echo "Deploying application..."
    // Environment-specific deployment logic
}

return this

And loaded it in my Jenkinsfile:

def gv

pipeline {
    stages {
        stage("init") {
            steps {
                script {
                    gv = load "script.groovy"
                }
            }
        }
        stage("build") {
            steps {
                script {
                    gv.buildJar()
                }
            }
        }
        stage("deploy") {
            steps {
                script {
                    gv.deployApp()
                }
            }
        }
    }
}

This is cleaner. But script.groovy lives in the project repository. Every project has its own copy. If 10 projects all need buildJar(), I'm still copying the same logic 10 times.

Stage 4: Multibranch Pipeline

Regular Pipeline job: Points to one Git branch. If you have feature branches, you need to create separate jobs manually for each one.

Multibranch Pipeline: Automatically discovers all branches in your repository and creates pipeline jobs for each one. Push a new branch? Jenkins creates a job. Delete a branch? Jenkins removes the job.

How I configured it:

Key benefit: The BRANCH_NAME environment variable is automatically available in multibranch pipelines. This is why my buildJar() function can echo "building the application for branch $BRANCH_NAME"—it works for master, develop, feature branches, all automatically.

Stage 5: Shared Library (This Project)

This is where I finally extracted the common logic into a separate repository that all projects can reference.

Why this progression matters: My current Jenkinsfile still loads script.groovy for deployApp(). You might wonder why, if I have a shared library. Answer: deployment logic is environment-specific. Build logic (Maven, Docker) is universal. Universal logic moves to the shared library. Environment-specific logic stays in the project.

Timeline: This entire progression (Freestyle → Pipeline → Scripts → Multibranch → Shared Library) took about a week of learning. The shared library project itself took 4 hours in one afternoon — but only because I'd already built the foundational knowledge.

Early Problems: The Maven Version Issue

Before I could even build pipelines successfully, I had to fix the application itself. The Java Maven app had a pom.xml with this:

<version>3.5.5.RELEASE</version>
Jenkins Build Error
Could not find artifact org.springframework.boot:spring-boot-maven-plugin:jar:3.5.5.RELEASE

The problem: Spring Boot 3.5.5 exists, but the version string 3.5.5.RELEASE doesn't. Spring Boot 2.x used the .RELEASE suffix, but Spring Boot 3.x dropped it. Someone mixed the old naming convention with the newer version number.

The fix: Changed both occurrences to 3.5.5. This was the second time I hit version compatibility issues (first was Gradle in Project 1), reinforcing that dependency management is a constant concern in Java projects.

Part 1: Early Pipeline Syntax Errors

During my initial learning of Pipeline jobs, I hit several Groovy syntax errors that taught me the language's quirks.

The First Error: Missing Colon

I was trying to create an input prompt to select deployment environment:

env.ENV = input message: "Select environment", ok "Done", parameters: [choice(name: 'ONE', choices: ['dev', 'staging', 'prod'])]
Error
expecting '}', found ',' @ line 44, column 94.
   nment to deploy to", ok "Done", paramete
                                 ^

The problem: ok "Done" should be ok: "Done". Groovy named parameters need colons.

env.ENV = input message: "Select environment", ok: "Done", parameters: [...]

The Second Error: Variable Typo

After fixing the syntax, I got a runtime error:

Error
No such property: ECHO for class: groovy.lang.Binding

I had defined ENV, then tried to use ECHO. Classic typo. Fixed it to ${ENV}.

How to Read Jenkins Errors

Jenkins stack traces are intimidating. Here's the pattern:

  1. First line after "exception:" — The actual error
  2. WorkflowScript.run(line X) — Which line in your Jenkinsfile
  3. Everything else — Jenkins internal code (usually ignore)
No such property: ECHO for class: groovy.lang.Binding
WorkflowScript.run(WorkflowScript:47)

Translation: "Line 47 is trying to use a variable called ECHO, but it doesn't exist." Once I understood this pattern, debugging got much faster.

Part 2: Creating the Shared Library Repository

With Jenkins working and pipelines building successfully, I started building the shared library. First step: create the repository structure.

The Required Structure

Jenkins Shared Library has a specific directory structure:

jenkins-shared-library/
├── vars/              # Global functions (the main folder)
│   ├── buildJar.groovy
│   ├── buildImage.groovy
│   ├── dockerLogin.groovy
│   └── dockerPush.groovy
├── src/               # Helper classes
│   └── com/example/
│       └── Docker.groovy
└── resources/         # Non-Groovy files (SQL, shell scripts, JSON)

The vars/ folder: Contains functions you call directly from Jenkinsfiles. Each .groovy file becomes a global function — file name = function name.

The src/ folder: Contains helper classes for complex logic. Standard Groovy class structure, imported and used by vars/ functions.

The resources/ folder: External libraries and non-Groovy files. I didn't use this for this project.

Creating the Repository

I created the shared library repository using this workflow:

1. Create the structure locally in IntelliJ:

jenkins-shared-library/
├── vars/
│   ├── buildJar.groovy
│   ├── buildImage.groovy
│   └── (other functions)
└── src/
    └── com/example/
        └── Docker.groovy

2. Create the repository on GitLab:

3. Initialize Git locally and connect to the remote:

cd jenkins-shared-library
git init
git add .
git commit -m "Initial commit"
git remote add origin https://gitlab.com/nvastola/jenkins-shared-library.git
git push -u origin master

Now the shared library is in version control and accessible to Jenkins.

Part 3: Writing the First Function (buildJar)

I started by extracting the Maven build logic into a shared function.

Original Jenkins pipeline step:

stage("build jar") {
    steps {
        script {
            echo "Building the application..."
            sh 'mvn package'
        }
    }
}

Extracted into vars/buildJar.groovy:

#!/user/bin/env groovy

def call() {
    echo "building the application for branch $BRANCH_NAME"
    sh 'mvn package'
}

Key concepts:

  1. The shebang line: #!/user/bin/env groovy tells your editor this is a Groovy script
  2. The call() method: Required. This is what gets executed when you call buildJar() from a Jenkinsfile
  3. Direct access to Jenkins commands: Inside vars/ functions, you can use echo, sh, withCredentials, etc. directly
  4. The $BRANCH_NAME variable: A Jenkins environment variable automatically available in multibranch pipelines

Updated Jenkinsfile:

@Library('jenkins-shared-library') _

pipeline {
    agent any
    stages {
        stage("build jar") {
            steps {
                script {
                    buildJar()  // That's it!
                }
            }
        }
    }
}

From ~5 lines of logic to 1 function call. And now every project can use buildJar() without rewriting it.

Groovy Syntax Note

Groovy lets you call functions with or without parentheses. You'll see both buildJar() and buildImage 'imageName' in Jenkins pipelines — both work the same way.

Part 4: The Docker Image Function (First Attempt)

Next, I extracted the Docker build logic. This is where things got complicated.

Original pipeline logic:

stage("build image") {
    steps {
        script {
            echo "Building the Docker image..."
            withCredentials([usernamePassword(credentialsId: 'nexus-credentials', usernameVariable: 'USER', passwordVariable: 'PASS')]) {
                sh "docker build -t my-app:${BUILD_NUMBER} ."
                sh "echo $PASS | docker login -u $USER --password-stdin nexus.example.com"
                sh "docker push my-app:${BUILD_NUMBER}"
            }
        }
    }
}

This worked! But the function did three different things. What if another project needs to login to Docker but not build? What if I want to build but push to a different registry?

Better approach: Split into three separate functions:

This is where I learned about the Single Responsibility Principle in pipeline code. Each function should do one thing.

Part 5: Using Classes (Understanding the script Object)

For better organization, I wanted to move the Docker logic into a proper Groovy class. This is where I learned an important concept about how Jenkins Shared Libraries work.

Inside vars/ functions, Jenkins pipeline commands (echo, sh, withCredentials) are available globally. But inside classes in src/, they're not available by default. You have to explicitly pass the Jenkins context through a special object called script.

Creating the Docker Class

Updated Docker class with the script pattern:

#!/user/bin/env groovy
package com.example

class Docker implements Serializable {
    
    def script  // This holds the Jenkins context
    
    Docker(script) {
        this.script = script
    }
    
    def buildDockerImage(String imageName) {
        script.echo 'building the docker image...'
        script.sh "docker build -t $imageName ."
    }
    
    def dockerLogin() {
        script.withCredentials([script.usernamePassword(credentialsId: '0849fe1b-1263-4101-9fcf-1a7bb0b567d4', passwordVariable: 'PASS', usernameVariable: 'USER')]) {
            script.sh "echo '${script.PASS} | docker login -u '${script.USER} --password-stdin"
        }
    }
    
    def dockerPush(String imageName) {
        script.sh "docker push $imageName"
    }
}

Key changes:

  1. implements Serializable — Required for Jenkins to save the state between pipeline steps
  2. Constructor takes script — We pass the Jenkins context when creating the object
  3. All commands use script. — script.echo, script.sh, script.withCredentials
  4. Variables use single quotes — Prevents interpolation issues with special characters in passwords

Why the Credential Syntax Is Weird

Look at this line:

script.sh "echo '${script.PASS} | docker login -u '${script.USER} --password-stdin"

Why the single quotes around the variables?

The Password Escaping Problem

I learned this the hard way. My first attempt used double quotes around the password variable. The credentials were correct, but Docker kept returning "unauthorized: incorrect username or password."

The problem: My password contained special characters ($, !, etc.) that bash was interpreting as variables or commands. Without the single quotes, the shell tried to expand $ symbols in the password.

The fix: Wrap variables in single quotes to pass them literally to the shell. Now bash treats the password as a literal string, special characters and all.

Using the Class from vars/

Now I could use this class from vars/buildImage.groovy:

#!/usr/bin/env groovy

import com.example.Docker

def call(String imageName) {
    return new Docker(this).buildDockerImage(imageName)
}

What's happening:

  1. Import the Docker class
  2. Create a new instance: new Docker(this)
  3. The this keyword passes the current Jenkins context (the script object)
  4. Call the method: .buildDockerImage(imageName)

Part 6: Splitting Into Multiple Functions

With the class working, I split the Docker functionality into separate functions:

vars/buildImage.groovy:

#!/usr/bin/env groovy
import com.example.Docker

def call(String imageName) {
    return new Docker(this).buildDockerImage(imageName)
}

vars/dockerLogin.groovy:

#!/usr/bin/env groovy
import com.example.Docker

def call() {
    return new Docker(this).dockerLogin()
}

vars/dockerPush.groovy:

#!/usr/bin/env groovy
import com.example.Docker

def call(String imageName) {
    return new Docker(this).dockerPush(imageName)
}

Updated Jenkinsfile (using global library):

@Library('jenkins-shared-library') _

pipeline {
    agent any
    tools {
        maven 'maven-3.9'
    }
    stages {
        stage("build jar") {
            steps {
                script {
                    buildJar()
                }
            }
        }

        stage("build and push image") {
            steps {
                script {
                    buildImage 'nvastola/demo-app:jma-3.0'
                    dockerLogin()
                    dockerPush 'nvastola/demo-app:jma-3.0'
                }
            }
        }
    }
}

Now each step is explicit and reusable independently. Another pipeline might only need dockerLogin() and dockerPush() if they're not building images in Jenkins.

The Real Iteration Process (What My Commits Show)

The post so far makes it seem like I wrote clean code that mostly worked. That's not what happened.

Looking at my commit history, I made 7 commits in about 1 hour:

  1. Initial commit — Basic structure
  2. fix docker credentials to push to private repo — Credential syntax struggle
  3. deleted brace that broke code buildImage — Syntax error (missing/extra })
  4. call docker build with param — Learning function parameters
  5. docker groovy changes - extracting logic — Moving to Docker class
  6. more docker groovy changes - splitting into separate steps — Refactoring
  7. fixed syntax error in buildImage.groovy — More syntax issues

That's one commit every 8–9 minutes. The cycle was: write code → push to GitLab → run Jenkins pipeline → see error → fix error → repeat.

The Brace Error That Cost Me 10 Minutes

I had written:

def buildDockerImage(String imageName) {
    script.echo 'building the docker image...'
        script.sh "docker build -t $imageName ."
    }  // ← Extra closing brace here
}
Error
WorkflowScript: 14: unexpected token: } @ line 14, column 5.
       }
       ^

I stared at this for 10 minutes. The error said "line 14" but my logic error was on line 13—I had indented script.sh incorrectly, making it look like it was inside the echo call, so I "fixed" it by adding an extra closing brace.

The actual fix was just proper indentation:

def buildDockerImage(String imageName) {
    script.echo 'building the docker image...'
    script.sh "docker build -t $imageName ."
}
Lesson

Groovy doesn't care about indentation, but humans do. If your indentation is wrong, you'll add syntax errors trying to "fix" it.

The Credential ID Hunt

In my Jenkinsfile, I initially wrote:

credentialsId: 'docker-hub-repo'
Error
could not find credentials entry with ID 'docker-hub-repo'

But I knew I had created Docker Hub credentials in Jenkins. I could see them in the UI with the description "Docker Hub credentials."

The problem: Jenkins credentials need the UUID, not the credential name. To find it:

  1. Jenkins → Manage Jenkins → Credentials
  2. Click "System" → "Global credentials"
  3. Click your credential
  4. Look at the browser URL: credentials/store/system/domain/_/credential/0849fe1b-1263-4101-9fcf-1a7bb0b567d4/
  5. Copy that UUID
Why This Is Confusing

When you create credentials in Jenkins, you give them a name and description. But the pipeline needs the ID, which is this long UUID only visible in the URL. Documentation rarely shows this step clearly. Write down the UUID immediately when creating credentials — don't hunt for it later.

The First Successful Run

After 7 commits, multiple syntax errors, the credential ID hunt, and the password escaping issue, I finally got a clean pipeline run.

Jenkins Console Output
Started by user noah
...
[Pipeline] stage (build jar)
[Pipeline] echo
building the application for branch master
[Pipeline] sh
+ mvn package
[INFO] BUILD SUCCESS

[Pipeline] stage (build and push image)
[Pipeline] echo
building the docker image...
[Pipeline] sh
+ docker build -t nvastola/demo-app:jma-3.0 .
Successfully tagged nvastola/demo-app:jma-3.0
[Pipeline] withCredentials
Login Succeeded
[Pipeline] sh
+ docker push nvastola/demo-app:jma-3.0
jma-3.0: digest: sha256:abc123... size: 2421

Finished: SUCCESS

That "Finished: SUCCESS" hit different. Not because the pipeline worked—that was expected after fixing all the errors. But because I realized: this exact same build logic can now be used by 10 other projects. Just reference the shared library and call the functions. No copying code. No maintaining duplicates.

I verified on Docker Hub by navigating to nvastola/demo-app → Tags → confirmed jma-3.0 was there. I also pulled and ran the image locally to verify the entire pipeline worked end-to-end.

Part 7: Making the Library Available in Jenkins

With the code written, I needed to make it available to Jenkins pipelines. There are two ways.

Option 1: Global Library (For Everyone)

Jenkins → Manage Jenkins → System → Global Pipeline Libraries:

In Jenkinsfile:

@Library('jenkins-shared-library') _

pipeline {
    // Your pipeline
}

The _ (underscore) imports the library but doesn't assign it to a variable. This is a Groovy convention. I started with this approach because it seemed simpler—configure once in Jenkins UI, use everywhere.

Option 2: Project-Specific (For One Pipeline)

After using the global library for a while, I switched to project-specific for more control:

#!/usr/bin/env groovy
library identifier: 'jenkins-shared-library@master', retriever: modernSCM(
    [$class: 'GitSCMSource',
    remote: 'https://gitlab.com/nvastola/jenkins-shared-library.git',
    credentialsId: 'b12881fa-58d1-4a47-828f-970217c47e67'])

pipeline {
    // Your pipeline
}

Why I switched:

Part 8: Versioning the Library

One powerful feature of Shared Libraries is versioning. You can reference specific versions in your Jenkinsfile:

@Library('jenkins-shared-library@v1.0.0') _  // Use tagged version

@Library('jenkins-shared-library@develop') _  // Use develop branch

@Library('jenkins-shared-library@abc123') _  // Use specific commit

Why this matters:

In production, you'd use Git tags:

git tag -a v1.0.0 -m "Initial stable release"
git push origin v1.0.0

For this learning project, I'm using @master to always pull the latest code. In a real environment with multiple teams, I would tag stable releases.

Part 9: Integrating the Library (The First Real Test)

With the shared library built and available in Jenkins, it was time for the moment of truth: actually using it in a pipeline.

The First Function Call

Before (inline code):

stage("build jar") {
    steps {
        script {
            echo "Building the application..."
            sh 'mvn package'
        }
    }
}

After (shared library function):

stage("build jar") {
    steps {
        script {
            buildJar()
        }
    }
}

Jenkins Console Output on first run:

Loading library jenkins-shared-library@master
...
[Pipeline] echo
building the application for branch master
[Pipeline] sh
+ mvn package
...
It Worked

The buildJar() function from my shared library executed. Jenkins loaded the library, found the function, and ran it.

Adding More Functions

stage("build and push image") {
    steps {
        script {
            buildImage 'nvastola/demo-app:jma-3.0'
            dockerLogin()
            dockerPush 'nvastola/demo-app:jma-3.0'
        }
    }
}

Notice the Groovy syntax: buildImage 'nvastola/demo-app:jma-3.0' works the same as buildImage('nvastola/demo-app:jma-3.0'). Groovy lets you omit parentheses for single-argument functions.

What About Deployment?

The deployment stage still calls gv.deployApp() from script.groovy rather than the shared library. This is intentional:

Deployment to dev vs staging vs production involves different servers, credentials, and configurations. That logic should live in the project repository where it can be customized per environment.

What I Learned (The Real Takeaways)

1. DRY Applies to Infrastructure Code Too

"Don't Repeat Yourself" isn't just for application code. Pipeline logic is code. If you're copying it, you're doing it wrong.

Before Shared Library:

After Shared Library:

2. The script Object Is Essential for Classes

Inside vars/ functions, Jenkins commands work directly. Inside src/ classes, you need the script object. Why? vars/ functions run in the Jenkins pipeline context automatically. Classes don't — they're just Groovy classes. You have to explicitly pass the Jenkins context.

// vars/myFunction.groovy
def call() {
    return new MyClass(this).doSomething()  // Pass 'this' as script
}

// src/com/example/MyClass.groovy
class MyClass implements Serializable {
    def script
    MyClass(script) { this.script = script }
    def doSomething() { script.echo "Hi" }
}

3. Single Responsibility Principle for Functions

Splitting buildAndPushImage() into three separate functions made the library more flexible:

4. Versioning Gives You Safety

Being able to pin pipelines to specific library versions means you can test changes without breaking production, roll back if something goes wrong, and migrate gradually. This is critical in enterprise environments where stability matters.

Production Considerations (What's Still Missing)

What I Built (Learning)

What Production Would Add

Testing:

Documentation:

Security:

Organization:

How This Connects to Real Enterprise DevOps

In a company with 100+ microservices, 10+ teams, and multiple programming languages, you might have:

The pattern scales:

jenkins-shared-library-core (v2.1.0)
├── Used by all 100 microservices
├── Managed by platform team
└── Changes require approval

jenkins-shared-library-payment (v1.5.0)
├── Used by payment microservices
├── Managed by payment team
└── Can change independently

Each team controls their own library but benefits from shared core functionality. This is how companies manage complexity at scale.

Reflection: The Learning Process

What worked:

What I'd do differently:

The Nexus Rebuild Lesson

Before this project, I deployed Nexus to a DigitalOcean droplet and manually configured it. Then I realized I'd set up the Docker repository wrong—it needed an HTTP connector, not HTTPS. My first instinct: rebuild the entire droplet from scratch.

Halfway through clicking through the Nexus UI for the second time, it hit me: I could have just run Nexus in Docker:

docker run -d \
  -p 8081:8081 \
  -p 8083:8083 \
  -v nexus-data:/nexus-data \
  sonatype/nexus3

Expose the ports during container creation, done. Configuration can be scripted or at least documented in a Dockerfile.

This is why Docker and infrastructure-as-code matter:

I spent 2 hours rebuilding manually when I could have spent 5 minutes with Docker. But you have to make the mistake to learn the lesson.

Why Docker Hub Instead of Nexus?

After the Nexus rebuild, I decided to keep Nexus for Maven artifacts (JAR files) but use Docker Hub for Docker images.

Reasoning:

  1. Learning opportunity: I wanted to learn how to push to both Nexus and Docker Hub from Jenkins
  2. Simplicity: Docker Hub doesn't require server management
  3. Cost: The Nexus droplet was temporary for learning — I wasn't going to keep paying $48/month for a personal project
  4. Separation of concerns: Maven artifacts in Nexus, Docker images in Docker Hub

In a real company, everything would probably go through Nexus (or Artifactory, or AWS ECR). But for learning, using different repositories for different artifact types taught me how to configure multiple destinations.

Time investment: ~4 hours in one afternoon (after a week of learning Jenkins fundamentals)
Cost: $48/month (DigitalOcean droplet: 4GB RAM, 4 vCPUs, 160GB storage for Jenkins)

Note on Cost

The Nexus droplet from Project 2 was temporary for learning and has been shut down. For this project, I only kept the Jenkins droplet running and used Docker Hub (free) for image storage.

Breakdown (one afternoon, ~4 hours):

What took the longest: The 7-commit iteration hour. Syntax errors, finding the credential UUID, figuring out password escaping — lots of small issues that added up. The script object pattern itself was straightforward once I understood it from the tutorial.

The Pattern I Now Understand

Manual work (Projects 1–2): SSH to server, run commands by hand. Copy files with scp. Understand what deployment actually does.

Containerization (Project 3): Package apps with dependencies. Build → Tag → Push → Pull → Deploy. Simulate CI/CD workflow manually.

Automation (Project 4, earlier): Write Jenkinsfiles to automate the workflow. One pipeline per project. Still copying logic between projects.

Scaled Automation (This project): Extract common logic into reusable functions. One library, many projects. Change once, affect everywhere.

Next: AWS Services for cloud infrastructure — because running everything locally or on a single DigitalOcean droplet doesn't scale to production.

What's Next

With Jenkins pipelines automated and shared libraries handling common logic, the next challenge is AWS Services. I've been working locally and with DigitalOcean, but real enterprise infrastructure runs on AWS.

I'm expecting:

But I'm also seeing how all these pieces connect: Docker packages the apps, Jenkins builds and deploys them, Shared Libraries keep the automation DRY, and AWS will provide the scalable infrastructure to run it all.

GitLab Repository →