So, you’ve been using Jenkins for a while, clicking through its web interface, setting up pipelines, and occasionally cursing when something breaks at 3 AM. But then one day, you think: “I wish Jenkins could do this specific thing.” You search for a plugin, scroll through hundreds of results, and nothing quite fits your needs. Well, my friend, it’s time to roll up your sleeves and build your own plugin. Don’t worry—developing Jenkins plugins with Groovy isn’t as intimidating as debugging a production incident without logs. In fact, it’s surprisingly straightforward once you understand the fundamentals. And bonus: you’ll finally have something impressive to show at your next team standup. This guide will take you from “I’ve never written a Jenkins plugin” to “I just shipped my first working plugin” with practical examples, real code, and none of that hand-wavy “implementation details are left as an exercise to the reader” nonsense.

Why Groovy for Jenkins Plugin Development

Before we dive into the code, let’s address the elephant in the room: why Groovy? Jenkins itself is written in Java, but Groovy brings something special to the table. It’s a JVM language that feels like scripting but has the power of Java underneath. You get dynamic typing, closures, and a syntax that doesn’t make you want to throw your keyboard across the room. Groovy runs directly inside Jenkins’ JVM, giving you access to all internal Jenkins objects and APIs. This means your plugin can interact with Jenkins at a deep level—manipulating builds, accessing configurations, and integrating with other plugins seamlessly. Plus, if you’ve written Jenkins pipelines before, you’re already familiar with Groovy syntax.

Prerequisites: What You Need Before Starting

Let me save you from the classic developer trap of diving in headfirst and hitting a wall two hours later. Here’s what you actually need: Development Environment:

  • JDK 8 or later (Jenkins still loves Java 8, though newer versions work too)
  • Maven 3.6+ (Jenkins plugins use Maven for build management)
  • Your favorite IDE (IntelliJ IDEA or Eclipse work great)
  • Git (for version control, because we’re professionals here) Knowledge Requirements:
  • Basic Java/Groovy syntax (you don’t need to be an expert, but know your way around classes and methods)
  • Familiarity with Jenkins concepts (jobs, builds, nodes)
  • Understanding of Maven project structure helps but isn’t mandatory Recommended Setup:
  • A local Jenkins instance for testing (Docker makes this trivial)
  • Patience (bugs will happen, embrace them)

Understanding Jenkins Plugin Architecture

Before writing code, let’s understand how Jenkins plugins actually work. Think of Jenkins as a modular system where plugins are like LEGO blocks—each one adds specific functionality and can interact with other blocks.

graph TB A[Jenkins Core] --> B[Plugin Manager] B --> C[Your Plugin] B --> D[Git Plugin] B --> E[Pipeline Plugin] C --> F[Extension Points] D --> F E --> F F --> G[Jenkins API] G --> A

Jenkins plugins work through extension points—predefined interfaces that plugins can implement to hook into Jenkins functionality. Your plugin implements these extension points, and Jenkins automatically discovers and integrates them at runtime. This is why you can install a plugin and immediately see new features without restarting Jenkins (well, most of the time).

Setting Up Your Development Environment

Let’s get our hands dirty. First, we need to set up the Maven project structure. Create a new directory for your plugin:

mkdir jenkins-sample-plugin
cd jenkins-sample-plugin

Now create a pom.xml file. This is your plugin’s DNA—it defines dependencies, build configuration, and metadata:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.jenkins-ci.plugins</groupId>
        <artifactId>plugin</artifactId>
        <version>4.40</version>
        <relativePath />
    </parent>
    <groupId>com.example</groupId>
    <artifactId>sample-jenkins-plugin</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>hpi</packaging>
    <name>Sample Jenkins Plugin</name>
    <description>A practical example of Jenkins plugin development</description>
    <properties>
        <jenkins.version>2.361.1</jenkins.version>
        <java.level>8</java.level>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.jenkins-ci.plugins.workflow</groupId>
            <artifactId>workflow-step-api</artifactId>
            <version>2.24</version>
        </dependency>
    </dependencies>
    <repositories>
        <repository>
            <id>repo.jenkins-ci.org</id>
            <url>https://repo.jenkins-ci.org/public/</url>
        </repository>
    </repositories>
    <pluginRepositories>
        <pluginRepository>
            <id>repo.jenkins-ci.org</id>
            <url>https://repo.jenkins-ci.org/public/</url>
        </pluginRepository>
    </pluginRepositories>
</project>

Notice the <packaging>hpi</packaging> line? HPI (Hudson Plugin Interface) is Jenkins’ special plugin format. It’s basically a JAR file with extra metadata. Jenkins knows how to load and execute these.

Creating Your First Plugin: Step-by-Step

Let’s build something useful—a plugin that retrieves the last build number from a Jenkins job. Simple, but it demonstrates the core concepts you’ll need for more complex plugins.

Step 1: Create the Directory Structure

Maven expects a specific directory layout. Create these folders:

mkdir -p src/main/java/com/example/jenkins/plugin
mkdir -p src/main/resources/com/example/jenkins/plugin

Step 2: Write the Plugin Class

Create a file src/main/java/com/example/jenkins/plugin/BuildNumberRetriever.java:

package com.example.jenkins.plugin;
import hudson.Extension;
import hudson.model.AbstractProject;
import hudson.model.Run;
import hudson.tasks.BuildStepDescriptor;
import hudson.tasks.Builder;
import jenkins.tasks.SimpleBuildStep;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.QueryParameter;
import javax.annotation.Nonnull;
import hudson.Launcher;
import hudson.model.AbstractBuild;
import hudson.model.BuildListener;
import hudson.util.FormValidation;
import java.io.IOException;
public class BuildNumberRetriever extends Builder implements SimpleBuildStep {
    private final String jobName;
    @DataBoundConstructor
    public BuildNumberRetriever(String jobName) {
        this.jobName = jobName;
    }
    public String getJobName() {
        return jobName;
    }
    @Override
    public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, 
                          BuildListener listener) throws IOException, InterruptedException {
        listener.getLogger().println("Retrieving last build number for job: " + jobName);
        AbstractProject<?, ?> project = (AbstractProject<?, ?>) 
            build.getProject().getParent().getItem(jobName);
        if (project == null) {
            listener.getLogger().println("Job not found: " + jobName);
            return false;
        }
        Run<?, ?> lastBuild = project.getLastBuild();
        if (lastBuild != null) {
            int buildNumber = lastBuild.getNumber();
            listener.getLogger().println("Last build number: " + buildNumber);
            // Set as environment variable for subsequent build steps
            build.addAction(new BuildNumberAction(buildNumber));
            return true;
        } else {
            listener.getLogger().println("No builds found for job: " + jobName);
            return false;
        }
    }
    @Extension
    public static final class DescriptorImpl extends BuildStepDescriptor<Builder> {
        @Override
        public boolean isApplicable(Class<? extends AbstractProject> jobType) {
            return true;
        }
        @Nonnull
        @Override
        public String getDisplayName() {
            return "Retrieve Last Build Number";
        }
        public FormValidation doCheckJobName(@QueryParameter String value) {
            if (value == null || value.isEmpty()) {
                return FormValidation.error("Job name is required");
            }
            return FormValidation.ok();
        }
    }
}

Let me break down what’s happening here because it’s not immediately obvious: @DataBoundConstructor: This annotation tells Jenkins how to instantiate your plugin from configuration data. When users configure your plugin in the UI, Jenkins uses this constructor. SimpleBuildStep: This interface makes your plugin compatible with both freestyle and pipeline jobs. It’s the modern way to write Jenkins plugins. @Extension: This is the magic annotation that registers your plugin with Jenkins. Without it, Jenkins won’t know your plugin exists. DescriptorImpl: Every plugin needs a descriptor—it’s metadata that tells Jenkins how to display and configure your plugin in the UI.

Step 3: Create the Action Class

Create src/main/java/com/example/jenkins/plugin/BuildNumberAction.java:

package com.example.jenkins.plugin;
import hudson.model.InvisibleAction;
public class BuildNumberAction extends InvisibleAction {
    private final int buildNumber;
    public BuildNumberAction(int buildNumber) {
        this.buildNumber = buildNumber;
    }
    public int getBuildNumber() {
        return buildNumber;
    }
}

This action class stores the build number so other build steps can access it. It extends InvisibleAction because we don’t need a UI component for it.

Step 4: Create the Configuration UI

Jenkins uses Jelly (yes, that’s actually what it’s called) for UI templates. Create src/main/resources/com/example/jenkins/plugin/BuildNumberRetriever/config.jelly:

<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
    <f:entry title="Job Name" field="jobName">
        <f:textbox />
    </f:entry>
</j:jelly>

This creates a simple text field where users can enter the job name. Jelly templates are Jenkins’ way of generating HTML forms.

Step 5: Add Help Documentation

Create src/main/resources/com/example/jenkins/plugin/BuildNumberRetriever/help-jobName.html:

<div>
    Enter the name of the Jenkins job whose last build number you want to retrieve.
    The build number will be available to subsequent build steps.
</div>

This help text appears when users click the question mark icon next to the job name field.

Building and Testing Your Plugin

Now for the moment of truth. Let’s build this thing:

mvn clean package

Maven will download dependencies (grab a coffee, this takes a while the first time), compile your code, and create an HPI file in the target directory. To test your plugin, you can run Jenkins directly from Maven:

mvn hpi:run

This starts a Jenkins instance with your plugin pre-installed at http://localhost:8080/jenkins. Create a freestyle job, add your “Retrieve Last Build Number” build step, and watch it work.

Adding Pipeline Support with Groovy

Here’s where Groovy really shines. Let’s add pipeline support so users can call your plugin from Jenkinsfiles. Create src/main/java/com/example/jenkins/plugin/BuildNumberRetrieverStep.java:

package com.example.jenkins.plugin;
import hudson.Extension;
import hudson.model.Run;
import hudson.model.TaskListener;
import org.jenkinsci.plugins.workflow.steps.Step;
import org.jenkinsci.plugins.workflow.steps.StepContext;
import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
import org.jenkinsci.plugins.workflow.steps.StepExecution;
import org.jenkinsci.plugins.workflow.steps.SynchronousNonBlockingStepExecution;
import org.kohsuke.stapler.DataBoundConstructor;
import javax.annotation.Nonnull;
import java.util.Collections;
import java.util.Set;
import jenkins.model.Jenkins;
import hudson.model.AbstractProject;
public class BuildNumberRetrieverStep extends Step {
    private final String jobName;
    @DataBoundConstructor
    public BuildNumberRetrieverStep(String jobName) {
        this.jobName = jobName;
    }
    public String getJobName() {
        return jobName;
    }
    @Override
    public StepExecution start(StepContext context) {
        return new Execution(jobName, context);
    }
    private static class Execution extends SynchronousNonBlockingStepExecution<Integer> {
        private final String jobName;
        Execution(String jobName, StepContext context) {
            super(context);
            this.jobName = jobName;
        }
        @Override
        protected Integer run() throws Exception {
            TaskListener listener = getContext().get(TaskListener.class);
            listener.getLogger().println("Pipeline: Retrieving build number for " + jobName);
            AbstractProject<?, ?> project = (AbstractProject<?, ?>) 
                Jenkins.get().getItemByFullName(jobName);
            if (project == null) {
                throw new Exception("Job not found: " + jobName);
            }
            Run<?, ?> lastBuild = project.getLastBuild();
            if (lastBuild != null) {
                int buildNumber = lastBuild.getNumber();
                listener.getLogger().println("Found build number: " + buildNumber);
                return buildNumber;
            } else {
                throw new Exception("No builds found for job: " + jobName);
            }
        }
    }
    @Extension
    public static class DescriptorImpl extends StepDescriptor {
        @Override
        public Set<? extends Class<?>> getRequiredContext() {
            return Collections.singleton(TaskListener.class);
        }
        @Override
        public String getFunctionName() {
            return "getLastBuildNumber";
        }
        @Nonnull
        @Override
        public String getDisplayName() {
            return "Get Last Build Number";
        }
    }
}

Now users can use your plugin in Jenkinsfiles like this:

pipeline {
    agent any
    stages {
        stage('Get Build Number') {
            steps {
                script {
                    def lastBuild = getLastBuildNumber('my-other-job')
                    echo "Last build was: ${lastBuild}"
                }
            }
        }
    }
}

Advanced Plugin Features with Groovy Scripts

Sometimes you need more flexibility than Java provides. Jenkins lets you embed Groovy scripts directly in your plugin for dynamic behavior. Create a Groovy script in src/main/resources/groovy/scripts/buildAnalyzer.groovy:

import jenkins.model.Jenkins
import hudson.model.Result
def analyzeBuild(String jobName) {
    def jenkins = Jenkins.get()
    def job = jenkins.getItemByFullName(jobName)
    if (!job) {
        return [error: "Job not found: ${jobName}"]
    }
    def builds = job.builds
    def totalBuilds = builds.size()
    def successfulBuilds = builds.findAll { it.result == Result.SUCCESS }.size()
    def failedBuilds = builds.findAll { it.result == Result.FAILURE }.size()
    return [
        total: totalBuilds,
        successful: successfulBuilds,
        failed: failedBuilds,
        successRate: totalBuilds > 0 ? (successfulBuilds / totalBuilds * 100).round(2) : 0
    ]
}
return this

You can call this script from your Java code:

import groovy.lang.GroovyShell;
import groovy.lang.Script;
public Map<String, Object> analyzeBuild(String jobName) throws Exception {
    GroovyShell shell = new GroovyShell();
    Script script = shell.parse(
        getClass().getResourceAsStream("/groovy/scripts/buildAnalyzer.groovy")
    );
    return (Map<String, Object>) script.invokeMethod("analyzeBuild", jobName);
}

Plugin Development Workflow Diagram

graph LR A[Write Code] --> B[Build with Maven] B --> C[Run Local Jenkins] C --> D[Test Plugin] D --> E{Works?} E -->|No| F[Debug] F --> A E -->|Yes| G[Package HPI] G --> H[Deploy to Production]

Best Practices for Jenkins Plugin Development

After building a few plugins, you learn some hard lessons. Let me save you from some common pitfalls: Always Validate User Input: Users will enter the most creative garbage into your forms. Validate everything in your descriptor’s doCheck methods. Use Logging Wisely: The listener.getLogger() is your friend for user-facing messages, but use Java logging (java.util.logging.Logger) for debug information. Handle Exceptions Gracefully: Plugins that crash Jenkins are not popular. Catch exceptions, log them, and return meaningful error messages. Test on Multiple Jenkins Versions: Jenkins evolves, and APIs change. Test your plugin on the minimum supported version and the latest LTS. Keep Dependencies Minimal: Every dependency you add is another potential conflict with other plugins. Be conservative. Document Your Code: Future you (or your team) will thank present you for writing clear comments and documentation.

Testing Your Plugin Properly

Testing is where many developers get lazy. Don’t be that developer. Create src/test/java/com/example/jenkins/plugin/BuildNumberRetrieverTest.java:

package com.example.jenkins.plugin;
import hudson.model.FreeStyleBuild;
import hudson.model.FreeStyleProject;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
public class BuildNumberRetrieverTest {
    @Rule
    public JenkinsRule jenkins = new JenkinsRule();
    @Test
    public void testBuildNumberRetrieval() throws Exception {
        // Create a sample job with builds
        FreeStyleProject project = jenkins.createFreeStyleProject("test-job");
        project.scheduleBuild2(0).get();
        project.scheduleBuild2(0).get();
        // Create job with our plugin
        FreeStyleProject testProject = jenkins.createFreeStyleProject("retriever-job");
        testProject.getBuildersList().add(new BuildNumberRetriever("test-job"));
        // Run the build
        FreeStyleBuild build = testProject.scheduleBuild2(0).get();
        // Verify
        assertNotNull(build);
        assertEquals(2, build.getAction(BuildNumberAction.class).getBuildNumber());
    }
}

Run tests with:

mvn test

Debugging Techniques

When things go wrong (and they will), here’s how to debug effectively: Remote Debugging: Start Jenkins with debug enabled:

export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,address=8000,suspend=n"
mvn hpi:run

Connect your IDE’s debugger to port 8000. Jenkins Script Console: Navigate to http://localhost:8080/jenkins/script and run Groovy scripts directly against Jenkins. This is invaluable for testing API calls:

import jenkins.model.Jenkins
def jenkins = Jenkins.get()
def job = jenkins.getItemByFullName('my-job')
println job.lastBuild.number

Check Jenkins Logs: The Jenkins logs ($JENKINS_HOME/logs/) often contain stack traces and error messages that aren’t visible in the UI.

Packaging and Distribution

Once your plugin works, it’s time to share it with the world (or at least your team). Create a Release Build:

mvn clean package -DskipTests

This creates a release-ready HPI file in the target directory without running tests. Install Manually: Upload the HPI file through Jenkins UI at Manage Jenkins > Manage Plugins > Advanced > Upload Plugin. Publish to Jenkins Update Center: If you want to share publicly, follow the Jenkins plugin hosting guidelines. You’ll need to publish your source code to GitHub and request inclusion in the update center.

Real-World Example: Build Quality Gate Plugin

Let’s build something more practical—a plugin that acts as a quality gate based on build metrics:

package com.example.jenkins.plugin;
import hudson.Extension;
import hudson.Launcher;
import hudson.model.AbstractBuild;
import hudson.model.AbstractProject;
import hudson.model.BuildListener;
import hudson.model.Result;
import hudson.tasks.BuildStepDescriptor;
import hudson.tasks.Builder;
import org.kohsuke.stapler.DataBoundConstructor;
public class QualityGateBuilder extends Builder {
    private final int maxFailureRate;
    private final int minBuildsRequired;
    @DataBoundConstructor
    public QualityGateBuilder(int maxFailureRate, int minBuildsRequired) {
        this.maxFailureRate = maxFailureRate;
        this.minBuildsRequired = minBuildsRequired;
    }
    public int getMaxFailureRate() {
        return maxFailureRate;
    }
    public int getMinBuildsRequired() {
        return minBuildsRequired;
    }
    @Override
    public boolean perform(AbstractBuild<?, ?> build, Launcher launcher,
                          BuildListener listener) {
        listener.getLogger().println("Evaluating quality gate...");
        AbstractProject<?, ?> project = build.getProject();
        int totalBuilds = 0;
        int failedBuilds = 0;
        for (Object buildObj : project.getBuilds()) {
            if (totalBuilds >= minBuildsRequired) break;
            AbstractBuild<?, ?> historicBuild = (AbstractBuild<?, ?>) buildObj;
            Result result = historicBuild.getResult();
            if (result != null) {
                totalBuilds++;
                if (result.isWorseOrEqualTo(Result.FAILURE)) {
                    failedBuilds++;
                }
            }
        }
        if (totalBuilds < minBuildsRequired) {
            listener.getLogger().println(
                "Not enough builds for quality gate evaluation. Need " + 
                minBuildsRequired + ", have " + totalBuilds
            );
            return true;
        }
        double failureRate = (double) failedBuilds / totalBuilds * 100;
        listener.getLogger().println(
            String.format("Failure rate: %.2f%% (max allowed: %d%%)", 
                         failureRate, maxFailureRate)
        );
        if (failureRate > maxFailureRate) {
            listener.getLogger().println("Quality gate FAILED!");
            build.setResult(Result.FAILURE);
            return false;
        }
        listener.getLogger().println("Quality gate PASSED!");
        return true;
    }
    @Extension
    public static final class DescriptorImpl extends BuildStepDescriptor<Builder> {
        @Override
        public boolean isApplicable(Class<? extends AbstractProject> jobType) {
            return true;
        }
        @Override
        public String getDisplayName() {
            return "Quality Gate Check";
        }
    }
}

This plugin analyzes recent build history and fails the build if the failure rate exceeds a threshold. It’s simple but demonstrates practical plugin logic.

Common Pitfalls and How to Avoid Them

Serialization Issues: Jenkins persists plugin data, so all fields in your plugin classes should be serializable. Use transient for fields you don’t need to persist. Thread Safety: Jenkins is multi-threaded. If your plugin maintains state, use proper synchronization or immutable objects. ClassLoader Hell: Jenkins uses a complex classloader hierarchy. If you’re getting ClassNotFoundException at runtime but compilation works, you likely have a dependency scope issue in your pom.xml. Performance: Plugins run in the Jenkins master JVM. Expensive operations (like network calls) should be asynchronous or run on agents.

Conclusion

Congratulations! You’ve gone from Jenkins user to Jenkins plugin developer. You now understand how plugins integrate with Jenkins, how to write both Java and Groovy code for plugins, and how to test and deploy your creations. The real learning happens when you start building your own plugins for actual problems. Start small—maybe a plugin that posts build notifications to your team chat, or one that enforces naming conventions for jobs. As you become more comfortable, you can tackle more complex integrations. Remember: every Jenkins plugin you see in the marketplace started exactly where you are now—with someone who had an idea and decided to build it. Your plugin could be the next one that saves thousands of developers from tedious manual work. Now go forth and automate something. Jenkins is waiting.