◀ Back to blog
DevOps

Configuring Jenkins pipelines

Published on 15 Jun 2024· 8 min read
#Jenkins#Pipeline#CI/CD

Jenkins Pipeline: advanced automation

Jenkins remains a popular CI/CD solution for organizations that need full control over their build infrastructure. Where GitHub Actions or GitLab CI tie you to their platform, Jenkins runs on your own servers, connects to any Git repository and can be extended through a very large plugin ecosystem. That freedom comes at a price: keeping the controller, agents and plugins up to date is your job.

Since Jenkins 2, the right way to describe a build is no longer to click through the UI but to write a versioned Jenkinsfile at the root of the project. The pipeline then evolves with the code: a branch can change its own build chain, and every change goes through review like any other modification.

Declarative or scripted?

Jenkins offers two syntaxes. A scripted pipeline is free-form Groovy: powerful, but hard to read and validate. A declarative pipeline enforces a structure (pipeline, agent, stages, post) that is checked before execution. For a typical PHP application, declarative covers every need; when complex logic becomes necessary, a script { } block or a shared library lets you step outside it occasionally.

Declarative pipeline

Here is a complete pipeline for a Symfony project: dependency installation, static analysis and tests in parallel, then deployment from the main branch.

pipeline {
    agent {
        dockerfile {
            filename 'Dockerfile.ci'
        }
    }

    environment {
        APP_ENV = 'test'
        DATABASE_URL = credentials('database-url')
    }

    stages {
        stage('Install') {
            steps {
                sh 'composer install --prefer-dist'
            }
        }

        stage('Quality') {
            parallel {
                stage('PHPStan') {
                    steps {
                        sh 'vendor/bin/phpstan analyse src'
                    }
                }
                stage('CS Fixer') {
                    steps {
                        sh 'vendor/bin/php-cs-fixer fix --dry-run --diff'
                    }
                }
            }
        }

        stage('Test') {
            steps {
                sh 'php bin/phpunit --log-junit results.xml'
            }
            post {
                always {
                    junit 'results.xml'
                }
            }
        }

        stage('Deploy') {
            when {
                branch 'main'
            }
            steps {
                sh './deploy.sh production'
            }
        }
    }

    post {
        failure {
            slackSend channel: '#ci', message: "Build FAILED: ${env.JOB_NAME}"
        }
    }
}

Reading the pipeline, block by block

  • agent { dockerfile { … } }: each build runs in a throwaway container created from the image described in the project's Dockerfile.ci (detailed below). The Docker Pipeline plugin is required, and the agent must have Docker available.
  • environment: defines variables visible to every step. credentials('database-url') fetches a secret stored in Jenkins; for a "Secret text" credential, the variable holds the value directly and Jenkins masks it in the logs.
  • parallel: PHPStan and PHP-CS-Fixer do not depend on each other, so they run at the same time. The parent stage fails if either one fails.
  • junit: placed in a post { always { } } block, it publishes the test report even when tests fail. Jenkins then shows the history and flaky tests.
  • when { branch 'main' }: this condition only works in a Multibranch Pipeline job, where Jenkins knows the branch name. In a plain Pipeline job, the stage would always be skipped.
  • post { failure { } }: the Slack notification is only sent on failure. slackSend is provided by the Slack Notification plugin.

A build image with Composer

The official php:8.3-cli image contains neither Composer, nor git, nor the zip extension. That is why the pipeline relies on a Dockerfile.ci versioned with the project, which adds what the build needs:

FROM php:8.3-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends git unzip libzip-dev libicu-dev openssh-client \
    && docker-php-ext-install zip intl pdo_mysql \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

With dockerfile { filename 'Dockerfile.ci' }, Jenkins builds the image, caches it on the agent and runs the build inside it. The CI environment is thus described in the repository instead of in a machine's configuration.

A more robust pipeline

The first example works, but it lacks several safeguards that are always added in production: a maximum duration, no concurrent builds on the same branch, history rotation, artifact archiving and a human approval before going to production.

pipeline {
    agent {
        dockerfile {
            filename 'Dockerfile.ci'
        }
    }

    options {
        timeout(time: 30, unit: 'MINUTES')
        disableConcurrentBuilds()
        buildDiscarder(logRotator(numToKeepStr: '20'))
        timestamps()
    }

    environment {
        APP_ENV = 'test'
        COMPOSER_HOME = "${env.WORKSPACE}/.composer"
    }

    stages {
        stage('Install') {
            steps {
                sh 'composer install --prefer-dist --no-progress --no-interaction'
            }
        }

        stage('Package') {
            steps {
                sh 'mkdir -p dist && tar --exclude=./dist --exclude=./.git -czf dist/app.tar.gz .'
                archiveArtifacts artifacts: 'dist/app.tar.gz', fingerprint: true
            }
        }

        stage('Deploy production') {
            when {
                branch 'main'
                beforeInput true
            }
            input {
                message 'Deploy to production?'
                ok 'Deploy'
                submitter 'release-managers'
            }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh './deploy.sh production'
                }
            }
        }
    }

    post {
        always {
            cleanWs()
        }
    }
}

A few explanations:

  • timeout aborts a stuck build (a test waiting for an unavailable service, for example) instead of tying up an executor for hours.
  • disableConcurrentBuilds() prevents two deployments of the same branch from overlapping.
  • COMPOSER_HOME points to the workspace: Jenkins starts the container with the agent's UID, whose home directory is often not writable inside the image.
  • input pauses the pipeline until a member of the release-managers group approves. With beforeInput true, the branch condition is evaluated before the prompt, so other branches are never blocked.
  • sshagent (SSH Agent plugin) loads the deployment key only for the duration of the step, without ever writing it to the workspace.
  • cleanWs() (Workspace Cleanup plugin) deletes the workspace at the end, so the next build starts from a clean state.

Common pitfalls

  • Secret interpolation: writing sh "mysql -p${DB_PASSWORD}" with double quotes makes Groovy interpolate the secret before execution, and Jenkins prints a security warning. Use single quotes, sh 'mysql -p"$DB_PASSWORD"', so the shell reads the environment variable itself.
  • Agent held during an input: with a global agent, the executor stays reserved while waiting for approval, and the global timeout keeps running. For long waits, declare agent none at pipeline level and one agent per stage.
  • Unmaintained plugins: every plugin is a dependency. Stick to the ones you need and update them regularly, as they are a frequent source of vulnerabilities.
  • Builds on the controller: configure zero executors on the built-in node and run builds on dedicated agents, to protect the controller and its secrets.

Validate the Jenkinsfile before pushing

A syntax error in a Jenkinsfile is often only discovered after the push. Jenkins exposes a linter for declarative pipelines, usable with an API token:

curl -X POST --user "$JENKINS_USER:$JENKINS_TOKEN" \
  -F "jenkinsfile=<Jenkinsfile" \
  "$JENKINS_URL/pipeline-model-converter/validate"

The response states whether the file is valid or points to the faulty line. This command fits easily into a Git hook or your editor.

Sharing code with Shared Libraries

When several projects share the same steps (notification, deployment, image publishing), duplicating the Jenkinsfile quickly becomes unmanageable. Shared Libraries let you put that common code in a separate Git repository, loaded with @Library('library-name') _ at the top of the Jenkinsfile. Each project then keeps a short, readable pipeline.

Jenkins best practices

  • Use Docker agents for isolation
  • Run independent stages in parallel
  • Store credentials in Jenkins Credentials
  • Set up notifications for failures
  • Archive build artifacts
  • Version the Jenkinsfile and the build image with the code
  • Always define a timeout and history rotation

When not to choose Jenkins

If your code is hosted on GitHub or GitLab and you have no particular hosting constraint, the tools built into those platforms require far less maintenance. Jenkins really makes sense when builds must stay on your network, access internal resources, or when you already have a team able to run the platform over the long term.