Overview #
My Setup #
In this tutorial I’m using the following setup based on Ubuntu 22.04 servers, GitLab is dockerized:
192.168.70.4 # GitLab
192.168.70.5 # GitLab Runner
192.168.70.6 # Deployment Server with Docker platform installed
I’m using the same setup for the deployment via SSH as described in my blog post:
GitLab CI Pipeline - Deploy website with Nginx Docker Container
The main requirements for the deployment via SSH key are the following:
-
Create a SSH key pair on the server where GitLab is deployed
-
Copy the public SSH key to the authorized keys file of the Deployment server
-
SSH into the deployment servers to add the fingerprint
-
Add the private SSH key as variable to the GitLab Repository CI/CD settings
-
On the deployment server, a user named “gitlab-deployment”, that is member of the Docker group, is used for the authentication of for the GitLab CI pipeline
Container Image Name #
The build container image name in the GitLab CI pipeline follows this naming convention: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA
-
$CI_REGISTRY_IMAGE
Points to the container registry for the project. -
$CI_COMMIT_REF_SLUG
Represents the branch or tag. -
$CI_COMMIT_SHA
Ensures every image is uniquely tied to a specific commit for traceability and reproducibility.
In my project named “flask-web-application”, the final image name looks like this:
# Container image name
gitlab-registry.jklug.work/python/flask-web-application/main:7f6d37fbc1aac00c89aba837b3ce083e75464290
-
Registry
: gitlab-registry.jklug.work/python -
Project
: flask-web-application -
Branch
: main -
Commit
: 7f6d37fbc1aac00c89aba837b3ce083e75464290
GitLab Repository #
File & Folder Structure #
The file and folder structure of the GitLab repository looks like this:
GitLab-Repository
├── Dockerfiles
│ ├── Dockerfile_Multistage_Binary # Runs compiled binary
│ ├── Dockerfile_Multistage_Bytecode # Runs compile bytecode
│ └── Dockerfile_PythonCode # Runs Python code
├── flask-app # Python Flask web application
│ ├── app.py
│ └── requirements.txt
├── .gitlab-ci.yml # GitLab CI Pipeline manifest
└── README.md
CI Pipeline Manifest #
- .gitlab-ci.yml
### Variables
variables:
DEPLOY_IP: "192.168.70.6" # Deployment server IP
DEPLOY_USER: "gitlab-deployment" # Deployment server SSH user
DEPLOY_PORT_HOST: 8080 # Host port
DEPLOY_PORT_CONT: 8080 # Container port
CONTAINER_NAME: "flask-app" # Name of the deployed container
# Define the image name, tagging it with the GitLab CI registry and the current commit SHA
IMAGE_SHA: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA
# Define Dockerfile to use, for example: "Dockerfile_PythonCode", "Dockerfile_Multistage_Bytecode", "Dockerfile_Multistage_Binary"
DOCKERFILE: Dockerfile_PythonCode
### Stages
stages:
- build
- deploy
### Build Container Image
build_image:
image: docker:stable
stage: build
services:
- docker:dind
variables:
DOCKER_TLS_CERTDIR: ""
before_script:
# Login to GitLab Container Registry using predefined CI/CD variables
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
# Build the Docker image from the specified Dockerfile in the Dockerfiles directory
- docker build --pull -t $IMAGE_SHA -f Dockerfiles/$DOCKERFILE .
# Push the built Docker image to the GitLab Container Registry
- docker push $IMAGE_SHA
rules:
# Rule: Run this job only for the main branch and if the specified Dockerfile exists
- if: $CI_COMMIT_BRANCH == "main"
exists:
- Dockerfiles/$DOCKERFILE
### Deploy Container to Virtual Machine
deploy_container:
stage: deploy
image: alpine:latest
needs:
- build_image # Run this job only if the 'build_image' job succeeds
before_script:
# Update the package index, install the OpenSSH client for SSH connections
- apk update && apk add openssh-client
# If the private SSH key file ($ID_RSA) exists, set secure permissions (read/write for the owner only)
- if [ -f "$ID_RSA" ]; then chmod og= $ID_RSA; fi
script:
# SSH into the deployment server, log in to the GitLab Container registry
- ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY"
# SSH into the deployment server, pull the image from the registry
- ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker pull $IMAGE_SHA"
# SSH into the deployment server, remove the existing container (if it exists)
- ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker container rm -f $CONTAINER_NAME || true"
# SSH into the deployment server, run the new container
- ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker run -d -p $DEPLOY_PORT_HOST:$DEPLOY_PORT_CONT --restart=unless-stopped --name $CONTAINER_NAME $IMAGE_SHA"
rules:
# Rule: Run this job only for main branch
- if: $CI_COMMIT_BRANCH == "main"
Dockerfile #
Basic Version: Python Code #
-
The following Dockerfile builds a Docker container that just runs a Python Flask web application.
-
This is a very basic setup intended for quick testing, the container image is about
134MB
in size. -
The
python:slim
image is based on Debian 12 Bookworm.
Dockerfile: “Dockerfile_PythonCode”
# Use the latest Python-slime (Debian 12) base image
FROM python:slim AS builder
# Set the working directory inside the container to /app
WORKDIR /app
# Copy the contents of the "flask-app" directory to "/app" inside the container
ADD flask-app/ /app/
# Install the dependencies listed in "requirements.txt"
RUN pip install -r requirements.txt
# Expose port 8080 for the application
EXPOSE 8080
# Create a non-root user and group
RUN groupadd appgroup && useradd -g appgroup appuser
# Set permissions for the application directory
RUN chown -R appuser:appgroup /app
# Switch to the non-root user
USER appuser
# Run the app.py file with Python when the container starts
ENTRYPOINT ["python", "app.py"]
Multistage Version: Bytecode #
-
This Dockerfile compiles the previous Python Flask web application into Python bytecode via the first stage of the Dockerfile and creates a new Alpine based lightweight container that runs the application.
-
Python Bytecode
.pyc
files still require the Python interpreter to execute and are not standalone executables. -
The final container image is about
57MB
in size. -
The
python:3.13-rc-alpine
image is based on Alpine Linux v3.20
Dockerfile: “Dockerfile_Multistage_Bytecode”
### Stage 1: Compile Python Application
# Use Python-slim image as the base image / Label image as "builder"
FROM python:3.13-slim AS builder
# Set the working directory inside the container to /app
WORKDIR /app
# Copy the contents of the "flask-app" directory to "/app" inside the container
ADD flask-app/. /app
# Install the dependencies listed in "requirements.txt"
RUN pip install -r requirements.txt
# Compile all Python files to bytecode and move the desired one to /app as app.pyc
RUN python -m compileall .
### Stage 2: Final Container with Bytecode
# Use a lightweight Python image for the final stage
FROM python:3.13-rc-alpine
# Copy requirements.txt from builder
COPY --from=builder /app/requirements.txt /tmp/
# Install app requirements
RUN pip install -r /tmp/requirements.txt
# Create system user "appuser" / no PW
RUN adduser -S -D -H -h /app appuser
# Switch to "appuser"
USER appuser
# Copy compiled .pyc files and any necessary files from the builder stage
COPY --from=builder /app/__pycache__/* /app/
# Define working directory
WORKDIR /app
# Expose port 8080 for the application
EXPOSE 8080
# Execute Bytecode when the container starts
ENTRYPOINT ["python", "/app/app.cpython-313.pyc"]
app.cpython-313.pyc
The name of the compiled Python bytecode must be adapted regarding the Python version, in this example it’s version 3.13.
Multistage Version: Binary #
-
This Dockerfile compiles the previous Python Flask web application into binary via the first stage of the Dockerfile and creates a new Debian based lightweight container that runs the binary.
-
The final container is about
120MB
in size.
Dockerfile: “Dockerfile_Multistage_Binary”
### Stage 1: Build and Compile with PyInstaller
# Use Python image as the base image / Label image as "builder"
FROM python:3.13 AS builder
# Set the working directory inside the container to /app
WORKDIR /app
# Copy the contents of the "flask-app" directory to "/app" inside the container
COPY flask-app/. /app
# Install the dependencies listed in "requirements.txt" and PyInstaller
RUN pip install -r requirements.txt pyinstaller
# Use PyInstaller to create a standalone binary for "app.py"
RUN pyinstaller --onefile app.py
### Stage 2: Final Container with Standalone Binary
# Use a lightweight Debian image for the final stage
FROM debian:bookworm-slim
# Install dependencies to run the Python binary
RUN apt-get update && apt-get install -y libstdc++6
# Define working directory
WORKDIR /app
# Copy the standalone binary from the builder stage
COPY --from=builder /app/dist/app /app/
# Create a non-root user
RUN useradd -m -d /app -s /bin/bash appuser
# Switch to "appuser"
USER appuser
# Expose port 8080 for the application
EXPOSE 8080
# Run the standalone binary when the container starts
ENTRYPOINT ["/app/app"]
Python Code #
Flask Application #
- flask-app/app.py
# Import the Flask class from the Flask library
from flask import Flask
# Create an instance of the Flask application
app = Flask(__name__)
# Define a route for the root URL ("/")
@app.route("/")
def main(): # Define a function that will execute when the root URL is accessed
return "Hi there from Python Flask application\n" # Return a simple string response to the client.
# Start the Flask app on all available network interfaces (0.0.0.0) and port 8080
app.run(host='0.0.0.0', port=8080)
Requirements #
- flask-app/requirements.txt
# Flask web framework version 3.1.0
Flask==3.1.0
Verify Deployment #
Basic Dockerfile Version #
Verify the container:
# List Docker containers
docker ps
# Shell output:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4c991e03924b gitlab-registry.jklug.work/python/flask-web-application/main:77c4f4c7f91fd8896776e3f2ab6e63d7fcc99c96 "python app.py" 25 seconds ago Up 25 seconds 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp flask-app
Verify the container user:
# Access the container terminal
docker exec -it 4c991e03924b sh
# List details of current user
id
# Shell output:
uid=1000(appuser) gid=1000(appgroup) groups=1000(appgroup)
Verify the container image:
# List docker images
docker images
# Shell output:
REPOSITORY TAG IMAGE ID CREATED SIZE
gitlab-registry.jklug.work/python/flask-web-application/main 77c4f4c7f91fd8896776e3f2ab6e63d7fcc99c96 586775fd1c42 2 minutes ago 134MB
Verify the Python Flask application:
# Curl the container
curl localhost:8080
# Shell output:
Hi there from Python Flask application
Multistage Dockerfile Version: Bytecode #
Verify the container:
# List Docker containers
docker ps
# Shell output:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f59ebd46e291 gitlab-registry.jklug.work/python/flask-web-application/main:7f6d37fbc1aac00c89aba837b3ce083e75464290 "python /app/app.cpy…" About a minute ago Up About a minute 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp flask-app
Verify the container user:
# Access the container terminal
docker exec -it f59ebd46e291 sh
# List details of current user
id
# Shell output:
uid=100(appuser) gid=65533(nogroup) groups=65533(nogroup)
Verify the container image:
# List docker images
docker images
# Shell output:
REPOSITORY TAG IMAGE ID CREATED SIZE
gitlab-registry.jklug.work/python/flask-web-application/main 7f6d37fbc1aac00c89aba837b3ce083e75464290 e6773b97bad5 About a minute ago 57.2MB
Verify the Python Flask application:
# Curl the container
curl localhost:8080
# Shell output:
Hi there from Python Flask application
Multistage Dockerfile Version: Binary #
Verify the container:
# List Docker containers
docker ps
# Shell output:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1e7e7654abfa gitlab-registry.jklug.work/python/flask-web-application/main:7a2ca6c8f86b2a7966b3f65d1c817059dcd04bda "/app/app" 2 minutes ago Up 2 minutes 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp flask-app
Verify the container user:
# Access the container terminal
docker exec -it 1e7e7654abfa sh
# List details of current user
id
# Shell output:
uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)
Verify the container image:
# List docker images
docker images
# Shell output:
REPOSITORY TAG IMAGE ID CREATED SIZE
gitlab-registry.jklug.work/python/flask-web-application/main 7a2ca6c8f86b2a7966b3f65d1c817059dcd04bda a172a8db5ebc 3 minutes ago 120MB
Verify the Python Flask application:
# Curl the container
curl localhost:8080
# Shell output:
Hi there from Python Flask application
Links #
# Latest Flask version
https://pypi.org/project/Flask/
GitLab CI Pipeline the containerizes and deploys a Python Flask web application.