ServerlessBase Blog
  • Introduction to Pipeline as Code

    Learn how to define CI/CD pipelines using version control, infrastructure as code principles, and declarative configurations for reliable deployments.

    Introduction to Pipeline as Code

    You've probably seen CI/CD pipelines defined in YAML files, JSON configurations, or even Python scripts. But have you ever wondered why we treat these pipelines like code? Why do we commit them to Git, review them in pull requests, and version them alongside our application code?

    The answer lies in treating your deployment pipeline as a first-class citizen in your development workflow. Pipeline as code (PaC) isn't just a buzzword—it's a fundamental shift in how teams approach automation, reliability, and collaboration.

    When you define your CI/CD pipeline using version control, you gain the same benefits you get from versioning your application code: traceability, reviewability, and the ability to roll back changes. A pipeline defined as code becomes a living document that evolves with your infrastructure, rather than a static script buried in a configuration file.

    What is Pipeline as Code?

    Pipeline as code means treating your CI/CD pipeline definitions as version-controlled source code. Instead of hard-coding pipeline logic in proprietary tools or maintaining fragile configuration files, you write declarative or imperative code that defines your build, test, and deployment processes.

    This approach applies the same principles of software engineering to your deployment automation:

    • Version Control: Your pipeline definitions live in Git repositories alongside your application code
    • Code Review: Team members review pipeline changes before merging, just like application code
    • Testing: You can write tests for your pipeline configurations to ensure they work correctly
    • Collaboration: Multiple team members can work on pipeline improvements simultaneously
    • Traceability: Every change to your pipeline has a commit history and author attribution

    The concept isn't new—infrastructure as code (IaC) has been around for years. Pipeline as code extends those same principles to your deployment automation, recognizing that pipelines are just another form of infrastructure that needs to be managed, tested, and versioned.

    Declarative vs Imperative Pipeline Definitions

    Before diving into implementation, it's important to understand the two main approaches to defining pipelines:

    Declarative Pipelines

    Declarative pipelines describe what you want to achieve, not how to achieve it. The pipeline engine handles the implementation details.

    # Example: Declarative pipeline using YAML
    pipeline:
      name: "My Application Pipeline"
      stages:
        - name: "Build"
          steps:
            - build:
                image: "node:18"
                commands:
                  - "npm install"
                  - "npm run build"
        - name: "Test"
          steps:
            - test:
                image: "node:18"
                commands:
                  - "npm test"
        - name: "Deploy"
          steps:
            - deploy:
                image: "docker"
                commands:
                  - "docker build -t myapp:latest ."
                  - "docker push myapp:latest"

    Declarative pipelines are easier to read and maintain because they focus on the pipeline's structure and stages rather than implementation details. Most modern pipeline tools (GitHub Actions, GitLab CI, Jenkins Pipeline) support declarative syntax.

    Imperative Pipelines

    Imperative pipelines define how to execute each step. You have more control over the execution flow but the code can become more complex.

    # Example: Imperative pipeline using shell script
    #!/bin/bash
     
    # Build stage
    echo "Building application..."
    docker build -t myapp:latest .
     
    # Test stage
    echo "Running tests..."
    npm test
     
    # Deploy stage
    echo "Deploying to production..."
    docker push myapp:latest

    Imperative pipelines are more flexible for complex logic but harder to maintain and review. They're often used for simple scripts or when you need fine-grained control over execution.

    Most teams prefer declarative pipelines for their readability and maintainability, especially when multiple team members need to understand and modify the pipeline.

    Benefits of Pipeline as Code

    1. Consistency and Reproducibility

    When your pipeline is defined as code, every team member uses the exact same configuration. This eliminates "it works on my machine" problems and ensures consistent deployments across environments.

    # This pipeline definition is the same for every developer
    pipeline:
      stages:
        - build
        - test
        - deploy

    2. Version Control and Traceability

    Every change to your pipeline has a commit history. You can see who made changes, when they were made, and why. This is crucial for debugging deployment issues and understanding why a particular pipeline configuration exists.

    # View pipeline history
    git log --oneline --all --grep="pipeline"

    3. Code Review and Collaboration

    Team members review pipeline changes before merging, just like application code. This catches errors early and ensures everyone agrees on how deployments should work.

    # Create a pull request for pipeline changes
    git checkout -b feature/update-pipeline
    git add .
    git commit -m "Update pipeline to use new Docker image"
    git push origin feature/update-pipeline
    # Open pull request for review

    4. Testing and Validation

    You can write tests for your pipeline configurations to ensure they work correctly. This catches configuration errors before they cause deployment failures.

    # Example: Testing pipeline configuration
    def test_pipeline_structure():
        pipeline = load_pipeline_config("pipeline.yaml")
        assert "stages" in pipeline
        assert len(pipeline["stages"]) >= 2  # At least build and test
        assert "deploy" in pipeline["stages"]

    5. Rollback Capabilities

    If a pipeline change causes problems, you can easily roll back to a previous version using Git. This is much faster than manually editing configuration files or contacting pipeline tool administrators.

    # Rollback pipeline to previous version
    git revert <commit-hash>
    git push origin main

    6. Documentation

    A well-written pipeline definition serves as living documentation. It shows exactly how your application is built, tested, and deployed, which is valuable for onboarding new team members and understanding the deployment process.

    Common Pipeline as Code Tools

    Several tools support pipeline as code, each with its own strengths:

    GitHub Actions

    GitHub Actions is a native CI/CD platform integrated directly into GitHub. Pipeline definitions are stored as YAML files in .github/workflows/ directories.

    # .github/workflows/deploy.yml
    name: Deploy to Production
     
    on:
      push:
        branches: [main]
     
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v3
          - name: Deploy
            run: |
              echo "Deploying to production..."
              # Deployment commands here

    GitLab CI

    GitLab CI is integrated into GitLab and uses .gitlab-ci.yml files. It's particularly strong for container-based workflows and has built-in container registry integration.

    # .gitlab-ci.yml
    stages:
      - build
      - test
      - deploy
     
    build:
      stage: build
      image: docker:latest
      script:
        - docker build -t myapp:latest .
     
    test:
      stage: test
      image: node:18
      script:
        - npm test
     
    deploy:
      stage: deploy
      image: docker:latest
      script:
        - docker push myapp:latest
      only:
        - main

    Jenkins Pipeline

    Jenkins supports both declarative and imperative pipeline definitions using Groovy scripts. Jenkins is highly extensible and has a large plugin ecosystem.

    // Jenkinsfile (declarative)
    pipeline {
        agent any
        stages {
            stage('Build') {
                steps {
                    sh 'docker build -t myapp:latest .'
                }
            }
            stage('Test') {
                steps {
                    sh 'npm test'
                }
            }
            stage('Deploy') {
                steps {
                    sh 'docker push myapp:latest'
                }
            }
        }
    }

    CircleCI

    CircleCI uses YAML configuration files and offers a generous free tier for open-source projects. It's known for fast build times and good integration with various cloud providers.

    # .circleci/config.yml
    version: 2.1
     
    jobs:
      build:
        docker:
          - image: node:18
        steps:
          - checkout
          - run: npm install
          - run: npm run build
          - run: npm test
     
      deploy:
        docker:
          - image: docker:latest
        steps:
          - checkout
          - setup_remote_docker
          - run: docker build -t myapp:latest .
          - run: docker push myapp:latest
     
    workflows:
      version: 2
      build-and-deploy:
        jobs:
          - build
          - deploy:
              requires:
                - build

    Best Practices for Pipeline as Code

    1. Keep Pipelines Modular

    Break your pipeline into smaller, reusable components. This makes your pipeline easier to understand and maintain.

    # Shared pipeline template
    .shared-pipeline: &shared-pipeline
      image: node:18
      before_script:
        - npm install
      cache:
        paths:
          - node_modules/
     
    # Reusable pipeline
    build:
      <<: *shared-pipeline
      script:
        - npm run build
     
    test:
      <<: *shared-pipeline
      script:
        - npm test

    2. Use Stages to Organize Pipeline

    Group pipeline steps into logical stages (build, test, deploy) to make the pipeline structure clear.

    stages:
      - build
      - test
      - deploy
     
    build:
      stage: build
      # Build steps
     
    test:
      stage: test
      # Test steps
     
    deploy:
      stage: deploy
      # Deployment steps

    3. Parameterize Your Pipelines

    Use variables and parameters to make your pipeline reusable across different projects and environments.

    variables:
      DOCKER_IMAGE: "myapp"
      DEPLOY_ENV: "production"
     
    deploy:
      script:
        - docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHA .
        - docker push $DOCKER_IMAGE:$CI_COMMIT_SHA
        - kubectl set image deployment/myapp myapp=$DOCKER_IMAGE:$CI_COMMIT_SHA

    4. Implement Conditional Execution

    Use conditions to skip stages or steps based on context, such as pull requests or specific branches.

    deploy:
      stage: deploy
      script:
        - docker push myapp:latest
      only:
        - main
      except:
        - schedules

    5. Use Caching to Speed Up Builds

    Cache dependencies and build artifacts to reduce build times and save resources.

    cache:
      paths:
        - node_modules/
        - .gradle/

    6. Write Clear, Descriptive Comments

    Document complex pipeline logic with comments to make it easier for other team members to understand.

    # This stage builds the Docker image and tags it with the commit SHA
    build:
      stage: build
      script:
        - docker build -t myapp:$CI_COMMIT_SHA .
        - docker tag myapp:$CI_COMMIT_SHA myapp:latest

    7. Test Your Pipeline Changes

    Before merging pipeline changes, test them in a non-production environment to ensure they work correctly.

    # Test pipeline locally or in a staging environment
    git checkout -b test-pipeline-update
    # Make changes
    git commit -am "Test pipeline changes"
    git push origin test-pipeline-update
    # Create merge request and test

    8. Use Secret Management

    Never hard-code secrets in your pipeline definitions. Use secret management tools to securely store and access credentials.

    # Use secret variables instead of hardcoding
    variables:
      DOCKER_PASSWORD: $DOCKER_PASSWORD  # From CI/CD settings

    Practical Example: Building a Pipeline as Code

    Let's walk through a complete example of defining a pipeline as code for a Node.js application.

    Project Structure

    my-app/
    ├── .github/
    │   └── workflows/
    │       └── deploy.yml          # Pipeline definition
    ├── src/
    │   └── index.js
    ├── package.json
    └── Dockerfile

    Pipeline Definition

    # .github/workflows/deploy.yml
    name: Deploy Node.js Application
     
    on:
      push:
        branches: [main]
      pull_request:
        branches: [main]
     
    env:
      NODE_VERSION: "18"
      DOCKER_IMAGE: "myapp"
     
    jobs:
      build-and-test:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout code
            uses: actions/checkout@v3
     
          - name: Set up Node.js
            uses: actions/setup-node@v3
            with:
              node-version: ${{ env.NODE_VERSION }}
     
          - name: Install dependencies
            run: npm ci
     
          - name: Run linter
            run: npm run lint
     
          - name: Run tests
            run: npm test
     
          - name: Build application
            run: npm run build
     
      deploy:
        needs: build-and-test
        runs-on: ubuntu-latest
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        steps:
          - name: Checkout code
            uses: actions/checkout@v3
     
          - name: Set up Docker Buildx
            uses: docker/setup-buildx-action@v2
     
          - name: Login to Docker Registry
            uses: docker/login-action@v2
            with:
              registry: docker.io
              username: ${{ secrets.DOCKER_USERNAME }}
              password: ${{ secrets.DOCKER_PASSWORD }}
     
          - name: Build and push Docker image
            uses: docker/build-push-action@v4
            with:
              context: .
              push: true
              tags: |
                ${{ env.DOCKER_IMAGE }}:latest
                ${{ env.DOCKER_IMAGE }}:${{ github.sha }}
     
          - name: Deploy to Kubernetes
            run: |
              kubectl set image deployment/myapp \
                myapp=${{ env.DOCKER_IMAGE }}:${{ github.sha }}

    Dockerfile

    # Dockerfile
    FROM node:18-alpine
     
    WORKDIR /app
     
    COPY package*.json ./
    RUN npm ci --only=production
     
    COPY . .
     
    EXPOSE 3000
     
    CMD ["node", "src/index.js"]

    package.json

    {
      "name": "my-app",
      "version": "1.0.0",
      "scripts": {
        "start": "node src/index.js",
        "build": "echo 'No build step needed'",
        "test": "jest",
        "lint": "eslint src/"
      },
      "devDependencies": {
        "eslint": "^8.0.0",
        "jest": "^29.0.0"
      }
    }

    When you push to the main branch, this pipeline will:

    1. Checkout your code
    2. Set up Node.js 18
    3. Install dependencies
    4. Run linter and tests
    5. Build the application
    6. Build and push a Docker image
    7. Deploy the new image to Kubernetes

    Common Pitfalls to Avoid

    1. Hardcoding Secrets

    Never hard-code passwords, API keys, or other secrets in your pipeline definitions. Use secret management tools instead.

    # ❌ WRONG - Hardcoded secret
    deploy:
      script:
        - docker login -u admin -p mypassword registry.example.com
     
    # ✅ CORRECT - Using secret variables
    deploy:
      script:
        - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD registry.example.com

    2. Over-Complicating Pipelines

    Keep your pipeline simple and focused. Complex pipelines are harder to debug and maintain.

    # ❌ WRONG - Overly complex pipeline
    pipeline:
      stages:
        - stage1
        - stage2
        - stage3
        - stage4
        - stage5
        # Too many stages
     
    # ✅ CORRECT - Simple, focused pipeline
    pipeline:
      stages:
        - build
        - test
        - deploy

    3. Ignoring Pipeline Failures

    Always handle pipeline failures gracefully. Use proper error handling and notifications.

    # ❌ WRONG - No error handling
    deploy:
      script:
        - docker push myapp:latest
     
    # ✅ CORRECT - Error handling and notifications
    deploy:
      script:
        - docker push myapp:latest || exit 1
      on_failure:
        email: "team@example.com"

    4. Not Testing Pipeline Changes

    Always test pipeline changes before merging them to the main branch.

    # ❌ WRONG - Merging without testing
    git checkout main
    git merge feature/pipeline-update
     
    # ✅ CORRECT - Testing before merging
    git checkout -b test-pipeline-update
    # Make changes
    git commit -am "Test pipeline changes"
    git push origin test-pipeline-update
    # Create merge request and test

    5. Using Proprietary Tools

    Avoid tools that lock you into a specific vendor. Choose tools that support open standards and allow you to export your pipeline definitions.

    # ❌ WRONG - Proprietary tool
    pipeline:
      tool: "my-tool"
      config: "proprietary-format"
     
    # ✅ CORRECT - Open standard
    pipeline:
      stages:
        - build
        - test
        - deploy

    Conclusion

    Pipeline as code is a fundamental practice for modern DevOps teams. By treating your CI/CD pipelines as version-controlled code, you gain consistency, reproducibility, and collaboration benefits that transform how you approach deployment automation.

    The key takeaways are:

    • Treat pipelines like code: Version control, review, and test your pipeline definitions
    • Choose the right tool: Select a tool that fits your team's needs and supports open standards
    • Keep it simple: Focus on the essential stages and steps
    • Use best practices: Follow modular design, parameterization, and secret management
    • Test thoroughly: Validate pipeline changes before merging

    As you implement pipeline as code, you'll notice improvements in deployment reliability, team collaboration, and overall development velocity. Your pipelines become living documentation that evolves with your infrastructure, making it easier for new team members to understand and maintain the deployment process.

    Platforms like ServerlessBase can help simplify pipeline management by providing a unified interface for defining and managing your CI/CD pipelines, reducing the complexity of configuration and allowing you to focus on what matters most—delivering value to your users.

    Leave comment