Published on: January 28, 2025
14 min read
Learn how to get started building a robust continuous deployment pipeline in GitLab. Follow these step-by-step instructions, practical examples, and best practices.
Continuous deployment is a game-changing practice that enables teams to deliver value faster, with higher confidence. However, diving into advanced deployment workflows — such as GitOps, container orchestration with Kubernetes, or dynamic environments — can be intimidating for teams just starting out.
At GitLab, we're committed to making delivery seamless and scalable. By enabling teams to focus on the fundamentals, we empower them to build a strong foundation that supports growth into more complex strategies over time. This guide provides essential steps to begin implementing continuous deployment with GitLab, laying the foundation for your long-term success.
Before diving into the technical implementation, take time to map out your deployment workflow. Success lies in careful planning and a methodical approach.
In the context of continuous deployment, artifacts are the packaged outputs of your build process that need to be stored, versioned, and deployed. These could be:
container images for your applications
packages
compiled binaries or executables
libraries
configuration files
documentation packages
other artifacts
Each type of artifact plays a specific role in your deployment process. For example, a typical web application might generate:
a container image for the backend service
a ZIP archive of compiled frontend assets
SQL files for database changes
environment-specific configuration files
Managing these artifacts effectively is crucial for successful deployments. Here's how to approach artifact management.
A best practice to get you started with a clean structure is to establish a clear versioning strategy for your artifacts. When creating releases:
myapp:1.2.3
for a stable releasemyapp:latest
for automated deploymentsmyapp:1.2.3-abc123f
for debuggingmyapp:feature-user-auth
for feature testingImplement defined retention rules:
Set explicit expiration timeframes for temporary artifacts
Define which artifacts need permanent retention
Configure cleanup policies to manage storage
Secure your artifacts with proper access controls:
Implement Personal Access Tokens for developer access
Configure CI/CD variables for pipeline authentication
Set up proper access scopes
Consider your environments early, as they shape your entire deployment pipeline:
Development, staging, and production environment configurations
Environment-specific variables and secrets
Access controls and protection rules
Deployment tracking and monitoring approach
Be intentional as to where and how you'll deploy, these decisions matter and the benefits and drawbacks of each should be consider:
Infrastructure requirements (VMs, containers, cloud services)
Network access and security configurations
Authentication mechanisms (SSH keys, access tokens)
Resource allocation and scaling considerations
With our strategy defined and foundational decisions made, we can now translate these plans into a working pipeline. We'll build a practical example that demonstrates these concepts, starting with a simple application and progressively adding deployment capabilities.
Let's walk through implementing a basic continuous deployment pipeline for a web application. We'll use a simple HTML application as an example, but these principles apply to any type of application. We’re also going to deploy our application as a Docker image on a simple virtual machine. This will allow us to lean on a curated image with minimum dependencies, and to ensure no environment specific requirements are unintentionally brought in. By working on a virtual machine, we won’t be leveraging GitLab’s native integrations, allowing us to work on an easier but less scalable setup to begin with.
In this example, we’ll aim to containerize an application that we’ll run on a virtual machine hosted on a cloud provider. We’ll also test this application locally on our machine. This list of prerequisites is only needed for this scenario.
Provision a VM in your preferred cloud provider (e.g., GCP, AWS, Azure)
Configure network rules to allow access on ports 22, 80, and 443
Record the machine's public IP address for deployment
Generate a public/private key pair for the machine
In GitLab, go to Settings > CI/CD > Variables
Create a variable called GITLAB_KEY
Set Type to "File" (required for SSH authentication)
Paste the private key in the Value field
Define a USER variable, this is the user logging in and running the scripts on your VM
STAGING_TARGET
: Your staging server IP/domainPRODUCTION_TARGET
: Your production server IP/domain
docker login registry.gitlab.com
# The username if you are using a PAT is gitlab-ci-token
# Password: your-access-token
Start with a basic web application. For our example, we're using a simple HTML page:
<!-- index.html -->
<html>
<head>
<style>
body {
background-color: #171321; /* GitLab dark */
}
</style>
</head>
<body>
<!-- Your content here -->
</body>
</html>
Create a Dockerfile to package your application:
FROM nginx:1.26.2
COPY index.html /usr/share/nginx/html/index.html
This Dockerfile:
Uses nginx as a base image for serving web content
Copies your HTML file to the correct location in the nginx directory structure
Create a .gitlab-ci.yml
file to define your pipeline stages:
variables:
TAG_LATEST: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:latest
TAG_COMMIT: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHA
stages:
- publish
- deploy
Let's break it down:
TAG_LATEST
is made up of three parts:
$CI_REGISTRY_IMAGE
is the path to your project's container registry in
GitLabFor example: registry.gitlab.com/your-group/your-project
$CI_COMMIT_REF_NAME
is the name of your branch or tagFor example, if you're on main branch: /main
, and if you're on a feature
branch: /feature-login
:latest
is a fixed suffixSo if you're on the main branch, TAG_LATEST
becomes:
registry.gitlab.com/your-group/your-project/main:latest
.
TAG_COMMIT
is almost identical, but instead of :latest
, it uses:
$CI_COMMIT_SHA
which is the commit identifier, for example:
:abc123def456
.
So for that same commit on main branch, TAG_COMMIT
becomes: registry.gitlab.com/your-group/your-project/main:abc123def456
.
The reason for having both is TAG_LATEST
gives you an easy way to always
get the newest version, and TAG_COMMIT
gives you a specific version you
can return to if needed.
Add the publish job to your pipeline:
publish:
stage: publish
image: docker:latest
services:
- docker:dind
script:
- docker build -t $TAG_LATEST -t $TAG_COMMIT .
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $TAG_LATEST
- docker push $TAG_COMMIT
This job:
Uses Docker-in-Docker to build images
Creates two tagged versions of your image
Authenticates with the GitLab registry
Pushes both versions to the registry
Now that our images are safely stored in the registry, we can focus on deploying them to our target environments. Let's start with local testing to validate our setup before moving to production deployments.
Before deploying to production, you can test locally. We just published our image to the GitLab repository, which we’ll pull locally. If you’re unsure of the exact path, navigate to Deploy > Container Registry, and you should see an icon to copy the path of your image at the end of the line for the container image you want to test.
docker login registry.gitlab.com
docker run -p 80:80 registry.gitlab.com/your-project-path/main:latest
By doing so you should be able to access your application locally on your localhost address through your web browser.
You can now add a deployment job to your pipeline:
deploy:
stage: deploy
image: alpine:latest
script:
- chmod 400 $GITLAB_KEY
- apk add openssh-client
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- ssh -i $GITLAB_KEY -o StrictHostKeyChecking=no $USER@$TARGET_SERVER
docker pull $TAG_COMMIT &&
docker rm -f myapp || true &&
docker run -d -p 80:80 --name myapp $TAG_COMMIT
This job:
Sets up SSH access to your deployment target
Pulls the latest image
Removes any existing container
Deploys the new version
Enable deployment tracking by adding environment configuration:
deploy:
environment:
name: production
url: https://your-application-url.com
This creates an environment object in GitLab's Operate > Environments section, providing:
Deployment history
Current deployment status
Quick access to your application
While a single environment pipeline is a good starting point, most teams need to manage multiple environments for proper testing and staging. Let's expand our pipeline to handle this more realistic scenario.
For a more robust pipeline, configure staging and production deployments:
stages:
- publish
- staging
- release
- version
- production
staging:
stage: staging
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_COMMIT_TAG == null
environment:
name: staging
url: https://staging.your-app.com
# deployment script here
production:
stage: production
rules:
- if: $CI_COMMIT_TAG
environment:
name: production
url: https://your-app.com
# deployment script here
This setup:
Deploys to staging from your main branch
Uses GitLab tags to trigger production deployments
Provides separate tracking for each environment
Here and in our next step, we’re leveraging a very useful GitLab feature:
tags. By manually creating a tag in the Code > Tags section, the
$CI_COMMIT_TAG
gets created, which allows us to trigger jobs accordingly.
We'll be using GitLab's release capabilities through our CI/CD pipeline.
First, update your stages in .gitlab-ci.yml
:
stages:
- publish
- staging
- release # New stage for releases
- version
- production
Next, add the release job:
release_job:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
- if: $CI_COMMIT_TAG # Only run when a tag is created
script:
- echo "Creating release for $CI_COMMIT_TAG"
release: # Release configuration
name: 'Release $CI_COMMIT_TAG'
description: 'Release created from $CI_COMMIT_TAG'
tag_name: '$CI_COMMIT_TAG' # The tag to create
ref: '$CI_COMMIT_TAG' # The tag to base release on
You can enhance this by adding links to your container images:
release:
name: 'Release $CI_COMMIT_TAG'
description: 'Release created from $CI_COMMIT_TAG'
tag_name: '$CI_COMMIT_TAG'
ref: '$CI_COMMIT_TAG'
assets:
links:
- name: 'Container Image'
url: '$CI_REGISTRY_IMAGE/main:$CI_COMMIT_TAG'
link_type: 'image'
For meaningful automated release notes:
Use conventional commits (feat:, fix:, etc.)
Include issue numbers (#123)
Separate subject from body with blank line
If you want custom release notes with deployment info:
release_job:
script:
- |
DEPLOY_TIME=$(date '+%Y-%m-%d %H:%M:%S')
CHANGES=$(git log $(git describe --tags --abbrev=0 @^)..@ --pretty=format:"- %s")
cat > release_notes.md << EOF
## Deployment Info
- Deployed on: $DEPLOY_TIME
- Environment: Production
- Version: $CI_COMMIT_TAG
## Changes
$CHANGES
## Artifacts
- Container Image: \`$CI_REGISTRY_IMAGE/main:$CI_COMMIT_TAG\`
EOF
release:
description: './release_notes.md'
Once configured, releases will be created automatically when you create a Git tag. You can view them in GitLab under Deploy > Releases.
This is what our final YAML file looks like:
variables:
TAG_LATEST: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:latest
TAG_COMMIT: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHA
STAGING_TARGET: $STAGING_TARGET # Set in CI/CD Variables
PRODUCTION_TARGET: $PRODUCTION_TARGET # Set in CI/CD Variables
stages:
- publish
- staging
- release
- version
- production
# Build and publish to registry
publish:
stage: publish
image: docker:latest
services:
- docker:dind
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_COMMIT_TAG == null
script:
- docker build -t $TAG_LATEST -t $TAG_COMMIT .
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $TAG_LATEST
- docker push $TAG_COMMIT
# Deploy to staging
staging:
stage: staging
image: alpine:latest
rules:
- if: $CI_COMMIT_BRANCH == "main" && $CI_COMMIT_TAG == null
script:
- chmod 400 $GITLAB_KEY
- apk add openssh-client
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- ssh -i $GITLAB_KEY -o StrictHostKeyChecking=no $USER@$STAGING_TARGET "
docker pull $TAG_COMMIT &&
docker rm -f myapp || true &&
docker run -d -p 80:80 --name myapp $TAG_COMMIT"
environment:
name: staging
url: http://$STAGING_TARGET
# Create release
release_job:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
- if: $CI_COMMIT_TAG
script:
- |
DEPLOY_TIME=$(date '+%Y-%m-%d %H:%M:%S')
CHANGES=$(git log $(git describe --tags --abbrev=0 @^)..@ --pretty=format:"- %s")
cat > release_notes.md << EOF
## Deployment Info
- Deployed on: $DEPLOY_TIME
- Environment: Production
- Version: $CI_COMMIT_TAG
## Changes
$CHANGES
## Artifacts
- Container Image: \`$CI_REGISTRY_IMAGE/main:$CI_COMMIT_TAG\`
EOF
release:
name: 'Release $CI_COMMIT_TAG'
description: './release_notes.md'
tag_name: '$CI_COMMIT_TAG'
ref: '$CI_COMMIT_TAG'
assets:
links:
- name: 'Container Image'
url: '$CI_REGISTRY_IMAGE/main:$CI_COMMIT_TAG'
link_type: 'image'
# Version the image with release tag
version_job:
stage: version
image: docker:latest
services:
- docker:dind
rules:
- if: $CI_COMMIT_TAG
script:
- docker pull $TAG_COMMIT
- docker tag $TAG_COMMIT $CI_REGISTRY_IMAGE/main:$CI_COMMIT_TAG
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $CI_REGISTRY_IMAGE/main:$CI_COMMIT_TAG
# Deploy to production
production:
stage: production
image: alpine:latest
rules:
- if: $CI_COMMIT_TAG
script:
- chmod 400 $GITLAB_KEY
- apk add openssh-client
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- ssh -i $GITLAB_KEY -o StrictHostKeyChecking=no $USER@$PRODUCTION_TARGET "
docker pull $CI_REGISTRY_IMAGE/main:$CI_COMMIT_TAG &&
docker rm -f myapp || true &&
docker run -d -p 80:80 --name myapp $CI_REGISTRY_IMAGE/main:$CI_COMMIT_TAG"
environment:
name: production
url: http://$PRODUCTION_TARGET
This complete pipeline:
Publishes images to the registry (main branch)
Deploys to staging (main branch)
Creates releases (on tags)
Versions images with release tags
Deploys to production (on tags)
Key benefits:
Clean reproducible, local development and testing environment
Clear path to production environments with structure to build confidence in what is deployed
Pattern to recover from unexpected failures, etc.
Ready to scale/adopt more complex deployment strategies
Throughout implementation, maintain these principles:
Document everything, from variable usage to deployment procedures
Use GitLab's built-in features (environments, releases, registry)
Implement proper access controls and security measures
Plan for failure with robust rollback procedures
Keep your pipeline configurations DRY (Don't Repeat Yourself)
What next? Here are some aspects to consider as your continuous deployment strategy matures.
Enhance security through:
Protected environments with restricted access
Required approvals for production deployments
Integrated security scanning
Automated vulnerability assessments
Branch protection rules for deployment-related changes
Implement advanced deployment strategies:
Feature flags for controlled rollouts
Canary deployments for risk mitigation
Blue-green deployment strategies
A/B testing capabilities
Dynamic environment management
Establish robust monitoring practices:
Track deployment metrics
Set up performance monitoring
Configure deployment alerts
Establish deployment SLOs
Regular pipeline optimization
GitLab's continuous deployment capabilities make it a standout choice for modern deployment workflows. The platform excels in streamlining the path from code to production, offering built-in container registry, environment management, and deployment tracking all within a single interface. GitLab's environment-specific variables, deployment approval gates, and rollback capabilities provide the security and control needed for production deployments, while features like review apps and feature flags enable progressive delivery approaches. As part of GitLab's complete DevSecOps platform, these CD capabilities seamlessly integrate with your entire software lifecycle.
The journey to continuous deployment is an evolution, not a revolution. Start with the fundamentals, build a solid foundation, and gradually incorporate advanced features as your team's needs grow. GitLab provides the tools and flexibility to support you at every stage of this journey, from your first automated deployment to complex, multi-environment delivery pipelines.
Sign up for a free trial of GitLab Ultimate to get started with continous deployment today.