Simple GitLab CI/CD for Portainer
Small projects I run on Portainer and for which I keep their code on GitLab all have a similar setup for CI/CD. Together with scheduled builds this makes for an easy setup keeping projects up to date on Portainer.
Portainer stack
The project has to be configured on Portainer already (docker-compose, environment variables, etc.). Ensure that the image field contains a tag (GitLab’s CI/CD will update the tag value):
services:
app:
image: docker-repository/path/to/image:1.0-tag
...
GitLab CI/CD
In GitLab a docker image has to be set up created and stored in a repository. After building a docker image, the following can be used to update Portainer with that image. I assume that "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}"
is the unique image tag here. Ensure that at least something unique is used in the image tag. Something like 1.250331.15654
could also be used, where 1
is the major version (could be a project-wide variable), 250331
is the date (can be generated with a bash command, date +"%y%m%d"
), and 15654
is the CI_CONCURRENT_ID
environment variable or something.
Then add the following to gitlab-ci.yml
:
update-stack:
stage: deploy
image: alpine/curl:latest
variables:
API_URL: ${API_URL}
API_TOKEN: ${API_TOKEN}
STACK_ID: ${STACK_ID}
script:
- |
apk add jq
api_call() {
local method="$1"
local endpoint="$2"
local data="${3:-}"
curl -X "$method" \
"${API_URL}${endpoint}" \
-H "X-API-Key: ${API_TOKEN}" \
-H "Content-Type: application/json" \
--silent \
${data:+-d "$data"}
}
# Fetch the stack info from Portainer
CURRENT_STACK=$(api_call "GET" "/stacks/${STACK_ID}")
CURRENT_STACK_FILE=$(api_call "GET" "/stacks/${STACK_ID}/file")
# Get the endpoint ID from the stack JSON
ENDPOINT_ID=$(echo "$CURRENT_STACK" | jq -r .EndpointId)
# Find and update the matching image in the stack file content using jq
# This regex pattern finds any line starting with "image: " followed by your registry path and replaces it with the new image tag
UPDATED_STACK_FILE=$(echo "$CURRENT_STACK_FILE" | jq --arg new_image "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}" ".StackFileContent | gsub(\"image: ${CI_REGISTRY_IMAGE}.*\"; \"image: \(\$new_image)\")")
# Create the stack update JSON
UPDATE_STACK=$(jq -n \
--argjson env "$(echo "$CURRENT_STACK" | jq .Env)" \
--argjson content "$UPDATED_STACK_FILE" \
'{env: $env, prune: true, pullImage: true, stackFileContent: $content}')
# Update the stack with the new image, causing the container to start with the new image
OUTPUT=$(api_call "PUT" "/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" "$UPDATE_STACK")
echo "$OUTPUT"
if echo "$OUTPUT" | grep -v "{\"Id\":${STACK_ID},"; then
exit 1
fi
echo "Updated the stack with new image: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}"
only:
- main
- tags
NB I assume stage: deploy
matches a stage after building the docker image, and that this will run every time a pipeline is run on main
or when a tag
has been created.
Finally, the environment variables required in GitLab:
API_URL
: has to match the URL that GitLab can access Portainer on, like:https://my-portainer:9443/api
API_TOKEN
: has to match the value of the access token, which is a Portainer access token.STACK_ID
: has to match the ID of the stack, which can be found by navigating through Portainer (where the project was set up and then get the integer from the URL). For example, inhttps://my-portainer:9443/#!/2/docker/stacks/my-app?id=123&type=2®ular=true&orphaned=false&orphanedRunning=false
the value would be123
.
This updates the project on Portainer automatically, and can be tested by manually running a pipeline on main
or on updating anything on the main
branch.