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 createdAnd when the branch is deleted:
Github sends delete event
↓
Jenkins triggers CLEANUP
↓
Docker stack removedPrerequisites
This tutorial builds on the following posts:
- Containerize Your Angular Frontend & Java Backend
- CI/CD Pipeline with Jenkins
- Liquibase for Database Schema Management
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
contextsproperty from.envorapplication.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:
- webnetHere's an explanation of changes done in this file
Dynamic Environment Variables
- Introduced parameterization for:
- Ports (
${UI_PORT}instead of hardcoded8081) - Paths (
${STACK_DIR}instead of./) - Env files (
${STACK_DIR}/.env.localinstead of.env.local)
- Ports (
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 Dashboard→Manage Jenkins→Manage Plugins. - Open the Available tab and search for
Generic Webhook Trigger. - Check the plugin and click Install without restart.
- Go to your
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
pipelinesection 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
- Set Definition as
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
+ Addto add a parameter- Add Parameter to capture branch information from webhook's payload
- Set variable as
BRANCH_NAME - Set Expression as
$.ref - Choose
JSONPath
- Set variable as
- Add Parameter to capture branch deleted flag from webhook's payload
- Set variable as
deleted - Set Expression as
$.deleted - Choose
JSONPath
- Set variable as
- Add Parameter to capture branch information from webhook's payload
- Click on
+ Addto add a header parameter- Add Parameter to capture
X-GitHub-Eventfrom webhook's header- Set Request header as
X-GitHub-Event
- Set Request header as
- Add Parameter to capture
- Set a relevant
tokenso the plugin can identify job associated with webhook. In my case I am setting it tosample-app-ephemeral

Creating Deployment Pipeline in Jenkins
We can follow the same steps above but change only two thing
- Set Name of job as
sample-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:
- Navigate to
Settings→Webhooks→Add webhook - Set the Payload URL to your Jenkins webhook
https://<JENKINS_URL>/generic-webhook-trigger/invoke?token=sample-app-ephemeral - Set Content type to
application/json. - Select the events that should trigger the pipeline:
Pushes,Branch or tag deletion - 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: