From CI/CD to Ephemeral Environments: Scaling Feature Delivery the Right Way

In the previous posts of this series, We gradually improved the deployment workflow of an Angular and Java application.

First, we containerized the stack using Docker so both the frontend and backend could run consistently across environments. Then introduced a Jenkins pipeline to automate builds and deployments. Finally, we integrated Liquibase into the backend so database schema changes could be versioned and applied automatically.

With these pieces in place, the application could already be built and deployed reliably.

But there was still one thing missing: an easy way to test feature branches before merging them.

Developers still had to run the project locally or deploy changes manually to a shared environment. This made collaboration and review slower than it needed to be.

A better solution is to create temporary preview environments for each branch — environments that spin up automatically when code is pushed and disappear once the work is finished.

In this post, I'll show how I built an ephemeral environment system using Forgejo, Jenkins, Docker, Liquibase, and Nginx Proxy Manager.

What Are Ephemeral Environments?

Ephemeral environments are temporary deployments of an application that are created automatically for a specific purpose, such as testing a feature branch or reviewing a pull request.

Each environment runs an isolated version of the application with all required services, including the frontend, backend, and database. Once the work is finished — for example when a branch is merged or deleted — the environment can be removed automatically.

This allows teams to test features in a real environment without affecting other development work.

Typical characteristics of ephemeral environments include:

  • Short-lived – created when needed and removed when no longer required
  • Isolated – each environment runs independently from others
  • Automated – created and destroyed by CI/CD pipelines
  • Branch-based – often tied to feature branches or pull requests
  • Production-like – includes all services needed to run the application

Using ephemeral environments allows developers and reviewers to test changes in parallel and catch issues earlier in the development cycle.

Architecture Overview

Here’s what we’re building:

Developer pushes branch
        ↓
Github Webhook
        ↓
Jenkins Pipeline
        ↓
Docker Compose stack created

And when the branch is deleted:

Github sends delete event
        ↓
Jenkins triggers CLEANUP
        ↓
Docker stack removed

Prerequisites

This tutorial builds on the following posts:

If you’re not familiar with these concepts, please read them first. In this post, we’ll focus exclusively on ephemeral preview environments using your existing Dockerized apps and Jenkins pipelines.

Add Seed data from the Preview Environment

When spinning up an ephemeral preview environment, it’s often necessary to populate the database with temporary data for testing or validation. Instead of seeding the main database, we can leverage Liquibase contexts to apply only the relevant changesets for the ephemeral stack.

We will add a new file named ephemeral_seeded_data.sql in the  seed_data folder. Then add the following content in it

-- liquibase formatted sql
-- changeset git-username:ephemeral_dataset_v3 runOnChange:True; logicalFilePath:path-independent
SET FOREIGN_KEY_CHECKS = 0; 
TRUNCATE table user;
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (1,'Admin','User');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (2,'John','Smith');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (3,'Emily','Johnson');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (4,'Michael','Brown');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (5,'Sarah','Davis');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (6,'David','Wilson');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (7,'Jessica','Taylor');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (8,'Daniel','Anderson');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (9,'Ashley','Thomas');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (10,'Matthew','Moore');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (11,'Olivia','Martin');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (12,'James','Lee');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (13,'Sophia','Perez');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (14,'Benjamin','Clark');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (15,'Isabella','Lewis');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (16,'William','Walker');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (17,'Mia','Hall');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (18,'Ethan','Allen');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (19,'Charlotte','Young');
INSERT INTO `user`(`id`,`first_name`,`last_name`) VALUES (20,'Alexander','King');
SET FOREIGN_KEY_CHECKS = 1;

ephemeral_seeded_data.sql

Update Master Changelog

  • Include ephemeral seed SQL in master.changelog.xml:
<!– Ephemeral Seeded Data -->
<include relativeToChangelogFile="true" file="./seed_data/ephemeral_seeded_data.sql" context="ephemeral-app-seed"></include>

master.changelog.xml

the context attribute ensures these changesets only run when the ephemeral context is active.

Update Liquibase Configuration

  • Inject a contexts property from .env or application.yml:
@Autowired
private DataSource dataSource;

@Value("${spring.liquibase.contexts:}")
private String contexts;

@Bean
public SpringLiquibase springLiquibase() {
    SpringLiquibase liquibase = new SpringLiquibase();
    liquibase.setDataSource(dataSource);
    liquibase.setChangeLog("db/changelog/master.changelog.xml");
    liquibase.setDatabaseChangeLogTable("database_change_log");
    liquibase.setDatabaseChangeLogLockTable("database_change_log_lock");

    // Set contexts if specified (for ephemeral demo data)
    if (contexts != null && !contexts.isEmpty()) {
        liquibase.setContexts(contexts);
    }

    // Ensure Liquibase runs on startup before other beans
    liquibase.setShouldRun(true);

    return liquibase;
}

LiquibaseConfig.java

This allows your pipeline to selectively apply only the relevant changesets for ephemeral environments.

Define Contexts in application.yml

Add a placeholder for ephemeral seeds:

spring:
  liquibase:
    contexts: ${LIQUIBASE_CONTEXTS:sample-app-seed}

application.yml

When deploying an ephemeral stack, we will set LIQUIBASE_CONTEXTS=ephemeral-app-seed in the .env file.

With the ephemeral seed data in place, the next step is to extend our Jenkins pipeline so that each branch can automatically spin up its own preview environment, including the seeded database.

Creating a Self-Contained Docker Stack for the Application

Previously, the application relied on an external MariaDB database. While this works for shared environments, it becomes problematic when running multiple preview environments because all instances would use the same database.

For ephemeral environments, each deployment will include its own temporary database container. This makes the entire stack self-contained, consisting of:

  • Frontend container
  • Backend container
  • Database container

All containers run within the same Docker Compose stack and communicate through an isolated network. This ensures each preview environment is independent and does not affect other running instances.

We will start by creating a new Docker Compose file named docker-compose.ephemeral.yml inside the deployment directory. This file will define the containers required for running a self-contained preview environment.

Add the following content to the file:

networks:
  webnet:
    driver: bridge

services:
    # Frontend UI 
    sample-app-frontend:
        image: "ghcr.io/navalgandhi1989/automated-preview-environments/sample-app-frontend:${UI_BUILD_NUMBER:-latest}"
        restart: unless-stopped
        ports:
          - "${UI_PORT}:80"
        networks:
          - webnet
        volumes:
          - ${STACK_DIR}/nginx.conf:/etc/nginx/nginx.conf:ro # Mount NGINX configuration file

    # Backend API 
    sample-app-backend:
      image: "ghcr.io/navalgandhi1989/automated-preview-environments/sample-app-backend:${BACKEND_BUILD_NUMBER:-latest}"
      restart: unless-stopped
      env_file:
        - ${STACK_DIR}/.env.local
      networks:
        - webnet



    # MariaDB for environment
    sample-app-mariadb:
      image: mariadb:12.2.2-noble
      restart: unless-stopped
      environment:
        MARIADB_ROOT_PASSWORD: root
        MARIADB_DATABASE: sample_app
      ports:
        - "${MARIADB_PORT:-0}:3306"
      volumes:
        - ${STACK_DIR}/mariadb:/var/lib/mysql
      networks:
        - webnet

Here's an explanation of changes done in this file

Dynamic Environment Variables

  • Introduced parameterization for:
    • Ports (${UI_PORT} instead of hardcoded 8081)
    • Paths (${STACK_DIR} instead of ./)
    • Env files (${STACK_DIR}/.env.local instead of .env.local)

this makes the stack configurable and reusable with different branches

Database Service Added

  • Made the stack self-contained, no external DB required.

After setting up docker-compose.ephemeral.yml, the next step is to create a .env.template file. This file defines all the configuration variables your stack will use, making it easy to spin up multiple ephemeral environments with minimal changes.


# Preview Environment Configuration
# =============================
# This file contains environment variables for the Preview environment only.
# Never use these values in production.

# Database Configuration
MARIADB_HOST_NAME=sample-app-mariadb
MARIADB_DB_NAME=sample_app
MARIADB_USER=root
MARIADB_PASSWORD=root

# Logging
JPA_SHOW_SQL=true
JPA_FORMAT_SQL=false

LIQUIBASE_CONTEXTS=ephemeral-app-seed

.env.template

Bringing It All Together: Jenkins Pipelines & GitHub Webhooks

With the foundation in place, it’s time to make our ephemeral environments fully automated. The goal is simple: whenever you push a feature branch or open a pull request, Jenkins builds, deploys, and tears down an environment automatically. This ensures every change can be tested in isolation without manual intervention.

Setting Jenkins Webhook Pipeline

We will create a dedicated Jenkins files to handle the ephemeral environment.

First create a file named jenkins-ephemeral-build-deployment.groovy and add following content in it

pipeline {
    agent any

    parameters {
        string(name: 'BRANCH_NAME', defaultValue: 'main', description: 'Branch to build and deploy')
        booleanParam(name: 'CLEANUP', defaultValue: false, description: 'Remove ephemeral environment')
    }

    environment {
        BUILD_TAG = 'latest'
        UI_BUILD_NUMBER = 'latest'
        BACKEND_BUILD_NUMBER = 'latest'
		CLEANED_BRANCH_NAME = ''
		PARSED_CLEANUP_FLAG = params.CLEANUP.toString()
    }

    stages {
	
		stage('Parse Webhook Parameters') {
            steps {
                script {
					CLEANED_BRANCH_NAME = params.BRANCH_NAME.replaceAll('refs/heads/', '')
					echo "Deploying branch: ${CLEANED_BRANCH_NAME}"
					
					echo "Github event: ${env.x_github_event}"
					
					if (env.x_github_event == "delete") {
						PARSED_CLEANUP_FLAG = "true"
					} 
					
					echo "Cleanup: ${PARSED_CLEANUP_FLAG}"
					
					
					CLEANED_BRANCH_NAME = params.BRANCH_NAME.replaceAll('refs/heads/', '')
					echo "Deploying branch: ${CLEANED_BRANCH_NAME}"

					echo "Github event: ${env.x_github_event}"
					echo "Deleted flag: ${env.deleted}"

					// Skip push deletion events
					if (env.x_github_event == "push" && env.deleted == "true") {
						PARSED_CLEANUP_FLAG = "true"
					}

					if (env.x_github_event == "delete") {
						PARSED_CLEANUP_FLAG = "true"
					}

					echo "Cleanup: ${PARSED_CLEANUP_FLAG}"
                    
                }
            }
        }
	
	
        stage('Build Docker Images') {
			when {
				expression { PARSED_CLEANUP_FLAG != 'true' }
			}
            steps {
                script {
                    def sampleAppBuild = build job: 'sample-app-build',
                          propagate: false,
                          wait: true, 
                          parameters: [
                              string(name: 'BRANCH_NAME', value: CLEANED_BRANCH_NAME)
                          ]
                    
                    BUILD_TAG = sampleAppBuild.displayName
                    echo "BUILD_TAG is: ${BUILD_TAG}"

                    if (BUILD_TAG == 'latest') {
                        error "Failed to capture BUILD_TAG from the sample-app-build."
                    }
                    
                    if (sampleAppBuild.result != 'SUCCESS') {
                        error "sample-app-build failed with status: ${sampleAppBuild.result}"
                    }
                }
            }
        }

        stage('Deploy Ephemeral Environment Images') {
            steps {
                script {
                    // Use the captured BUILD_TAG for deployment
                    def UI_BUILD_NUMBER = BUILD_TAG
                    def BACKEND_BUILD_NUMBER = BUILD_TAG
					boolean CLEANUP_FLAG = (PARSED_CLEANUP_FLAG == 'true')

                    def sampleAppEphemeralDeployment = build job: 'sample-app-ephemeral-deployment',
                          propagate: false,
                          wait: true, 
                          parameters: [
							  string(name: 'BRANCH_NAME', value: CLEANED_BRANCH_NAME),
                              string(name: 'UI_BUILD_NUMBER', value: UI_BUILD_NUMBER),
                              string(name: 'BACKEND_BUILD_NUMBER', value: BACKEND_BUILD_NUMBER),
                              booleanParam(name: 'CLEANUP', value: CLEANUP_FLAG)
                          ]
                                        
                    if (sampleAppEphemeralDeployment.result != 'SUCCESS') {
                        error "sample-app-ephemeral-deployment failed with status: ${sampleAppEphemeralDeployment.result}"
                    }
                }
            }
        }
    }

    post {
        always {
            echo 'Pipeline execution completed.'
        }
        success {
            echo 'Pipelines executed successfully.'
        }
        failure {
            echo 'Pipeline failed.'
        }
    }
}

jenkins-ephemeral-build-deployment.groovy

At a high level, the pipeline will

Parses webhook and branch parameters

  • Cleans up branch names, detects GitHub events (like pushes or deletes), and determines whether the ephemeral environment should be deployed or cleaned up.

Builds Docker images

  • Triggers a separate existing build job for the application, captures the build tag, and ensures the build succeeded before proceeding.

Deploys ephemeral environments

  • Uses the captured build tag to deploy both frontend and backend images to a temporary environment. It also handles automatic cleanup if the branch is deleted or a cleanup flag is set.

Now we will define a pipeline that handles the actual deployment and teardown of ephemeral environments on a remote server. It works with the build artifacts generated in the previous pipeline. Go ahead and create a file jenkins-ephemeral-deployment.groovy and add following content in it.

pipeline {
    agent any
    parameters {
        string(name: 'BRANCH_NAME', defaultValue: 'main', description: 'Branch to deploy')
        string(name: 'BACKEND_BUILD_NUMBER', description: 'Build number for Backend')
        string(name: 'UI_BUILD_NUMBER', description: 'Build number for UI')
		booleanParam(name: 'CLEANUP', defaultValue: false, description: 'Cleanup mode: Remove stack for this branch')
    }
    environment {
        SSH_USER = "root"
        SSH_HOST = "10.1.21.51"
        SSH_KEY = "SSH_KEY_CT-12251"
        TARGET_DIR = "/root/sample-app-ephemeral"
    }
    stages {
        stage('Prepare Variables') {
            steps {
                script {
                    STACK_NAME = params.BRANCH_NAME.replaceAll("[^a-zA-Z0-9-]", "-").toLowerCase()
                    UI_PORT = sh(script: "shuf -i 18000-18999 -n 1", returnStdout: true).trim()
                    MARIADB_PORT = sh(script: "shuf -i 15400-15999 -n 1", returnStdout: true).trim()
                    echo "Stack: ${STACK_NAME}"
                    echo "UI Port: ${UI_PORT}"
                    echo "MariaDB Port: ${MARIADB_PORT}"
                }
            }
        }
     
		
		stage('Cleanup Previous Stack') {
            when {
                expression {
                    params.CLEANUP
                }
            }
            steps {
                script {
                    sshagent(credentials: [SSH_KEY]) {
                        sh """
                      [ -d ~/.ssh ] || mkdir ~/.ssh && chmod 0700 ~/.ssh
                      ssh-keyscan -t rsa,dsa ${SSH_HOST} >> ~/.ssh/known_hosts
					  
					  ssh -t -t  ${SSH_USER}@${SSH_HOST} 'bash -s << 'ENDSSH'
								docker compose -p ${STACK_NAME} down 
								cd ${TARGET_DIR}
								rm -rf ${STACK_NAME}
								exit
                       ENDSSH'
                      """
                    }
                }
            }
        }
		
		stage('Prepare Environment File') {
            when {
                expression {
                    !params.CLEANUP
                }
            }
            steps {
                script {
                    sshagent(credentials: [SSH_KEY]) {
                        sh """
                      [ -d ~/.ssh ] || mkdir ~/.ssh && chmod 0700 ~/.ssh
                      ssh-keyscan -t rsa,dsa ${SSH_HOST} >> ~/.ssh/known_hosts
					  
					  ssh -t -t  ${SSH_USER}@${SSH_HOST} 'bash -s << 'ENDSSH'
								
								cd ${TARGET_DIR}
								mkdir -p ${STACK_NAME}
								
								cp nginx.conf ${STACK_NAME}/nginx.conf
								cp .env.template ${STACK_NAME}/.env.local
								cp docker-compose.ephemeral.yml ${STACK_NAME}/docker-compose.yml
								
								exit
                       ENDSSH'
                      """
                    }
                }
            }
        }
		
		stage('Deploy Stack') {
            when {
                expression {
                    !params.CLEANUP
                }
            }
            steps {
                script {
                    sshagent(credentials: [SSH_KEY]) {
                        sh """
                      [ -d ~/.ssh ] || mkdir ~/.ssh && chmod 0700 ~/.ssh
                      ssh-keyscan -t rsa,dsa ${SSH_HOST} >> ~/.ssh/known_hosts
					  
					  ssh -t -t  ${SSH_USER}@${SSH_HOST} 'bash -s << 'ENDSSH'
			
						docker compose -p ${STACK_NAME} down 
						cd ${TARGET_DIR}
						cd ${STACK_NAME}
						STACK_DIR=${TARGET_DIR}/${STACK_NAME} BACKEND_BUILD_NUMBER=${params.BACKEND_BUILD_NUMBER} UI_BUILD_NUMBER=${params.UI_BUILD_NUMBER} UI_PORT=${UI_PORT} MARIADB_PORT=${MARIADB_PORT} docker compose -p ${STACK_NAME} up -d
						exit
            
						ENDSSH'
                      """
                    }
                }
            }
        }
		
    
        stage('Print Access Info') {
			when {
                expression {
                    !params.CLEANUP
                }
            }
            steps {
                script {
					echo ""
					echo ""
					echo ""
                    echo "Ephemeral Environment Deployed"
                    echo "UI: http://${SSH_HOST}:${UI_PORT}"
                    echo "MariaDB: ${SSH_HOST}:${MARIADB_PORT}"
                    echo "Stack: ${STACK_NAME}"
                    echo "Branch: ${params.BRANCH_NAME}"
                }
            }
        }
    }
    post {
        failure {
            echo "Pipeline failed. Check logs above."
        }
        always {
            echo "Pipeline execution completed."
        }
    }
}

jenkins-ephemeral-deployment.groovy

The pipeline performs the following tasks:

Prepare deployment variables

  • Generates a sanitized stack name from the branch, randomly selects ports for the UI and MariaDB to avoid conflicts, and prints this info for visibility.

Cleanup previous deployments

  • If the cleanup flag is set (or the branch is deleted), it connects to the remote server over SSH and removes any existing ephemeral environment for that branch.

Prepare environment files

  • Copies configuration files, environment templates, and the Docker Compose setup into a new folder for the branch on the server.

Deploy the stack

  • Launches the ephemeral environment with the specified backend and UI build numbers, binding the previously assigned ports.

Display access information

  • Prints the URLs and ports for the deployed UI and MariaDB, along with the stack and branch name, so developers and reviewers can immediately access the environment.

With this, most of the our code changes are done. We will now move to Jenkins to hook everything up.

Installing the Generic Webhook Trigger Plugin in Jenkins

The Generic Webhook Trigger plugin allows Jenkins pipelines to respond to webhooks from any service — not just GitHub. This is especially useful for ephemeral environments where you want to trigger pipelines on branch events, deletes, or custom payloads.

  • Steps to install:
    • Go to your Jenkins DashboardManage JenkinsManage Plugins.
    • Open the Available tab and search for Generic Webhook Trigger.
    • Check the plugin and click Install without restart.

Creating Jenkins Job to Process the Webhook call

Click on + New Item in jenkins and

  • Set Name of job, I am using sample-app-build-ephemeral
  • Set item Type and Pipeline
  • Go to to the pipeline section of job and
    • Set Definition as Pipeline script from SCM
    • Set SCM as Git
    • Set Repository URL as url of the repo where jenkins pipeline files are present. In my case it is the app repo itself.
    • Set Credentials as your previously created git credentials
    • Set Branch Specifier (blank for 'any') as */main
    • Set Script Path as .deployment/jenkins-ephemeral-build-deployment.groovy

Configuring Webhook Plugin for job

With the job ready, we will make some changes so we can trigger it from GitHub. Go to the Triggers section of the job

  • Check Generic Webhook Trigger
  • Click on + Add to add a parameter
    • Add Parameter to capture branch information from webhook's payload
      • Set variable as BRANCH_NAME
      • Set Expression as $.ref
      • Choose JSONPath
    • Add Parameter to capture branch deleted flag from webhook's payload
      • Set variable as deleted
      • Set Expression as $.deleted
      • Choose JSONPath
  • Click on + Add to add a header parameter
    • Add Parameter to capture X-GitHub-Event from webhook's header
      • Set Request header as X-GitHub-Event
  • Set a relevant token so the plugin can identify job associated with webhook. In my case I am setting it to sample-app-ephemeral

Creating Deployment Pipeline in Jenkins

We can follow the same steps above but change only two thing

  • Set Name of job assample-app-ephemeral-deployment
  • Set Script Path as .deployment/jenkins-ephemeral-deployment.groovy

Note : We do not need to configure Webhook Trigger for this job.

Creating Github Webhook for repository

To trigger the pipeline automatically, configure a webhook in your GitHub repository:

  1. Navigate to SettingsWebhooks Add webhook
  2. Set the Payload URL to your Jenkins webhook https://<JENKINS_URL>/generic-webhook-trigger/invoke?token=sample-app-ephemeral
  3. Set Content type to application/json.
  4. Select the events that should trigger the pipeline: Pushes, Branch or tag deletion
  5. Save and test the webhook to confirm Jenkins is triggered correctly.

Testing the Build

With the pipelines and webhooks configured, the final step is to verify that everything works as expected.

Start by creating a new feature branch in your repository and pushing it to GitHub. This should trigger the webhook, which in turn starts the Jenkins pipeline automatically.

You can then monitor the pipeline execution in Jenkins → Build History. The pipeline should:

  • Parse the webhook payload and detect the branch
  • Trigger the build pipeline to create Docker images
  • Deploy a new ephemeral environment for the branch

Once the deployment stage completes, Jenkins will print the access details in the console logs, including the URL of the UI and the assigned ports.

Open the provided URL from console in your browser to confirm that the environment is running correctly.

To test the cleanup process, simply delete the branch or trigger the cleanup flag. Jenkins should detect the event and automatically remove the corresponding ephemeral environment from the server.

At this point, you should have a fully working workflow where every branch can automatically spin up its own temporary environment and clean itself up when it’s no longer needed.

Happy pipeline-building!

GitHub Repository:

You can find all the code from this blog on GitHub:

GitHub - navalgandhi1989/automated-preview-environments
Contribute to navalgandhi1989/automated-preview-environments development by creating an account on GitHub.