Published on: January 28, 2025
13 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:
Each type of artifact plays a specific role in your deployment process. For example, a typical web application might generate:
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:
Secure your artifacts with proper access controls:
Consider your environments early, as they shape your entire deployment pipeline:
Be intentional as to where and how you'll deploy, these decisions matter and the benefits and drawbacks of each should be consider:
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.
GITLAB_KEY
STAGING_TARGET
: Your staging server IP/domainPRODUCTION_TARGET
: Your production server IP/domaindocker 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:
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:
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:
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:
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:
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:
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:
Key benefits:
Throughout implementation, maintain these principles:
What next? Here are some aspects to consider as your continuous deployment strategy matures.
Enhance security through:
Implement advanced deployment strategies:
Establish robust monitoring practices:
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, 60-day trial of GitLab Ultimate to get started with continous deployment today.