Docker y CI/CD

Lineamientos para dockerizar aplicaciones Flask y configurar pipeline GitLab con ambientes staging y producción.

Contenedores y automatización — De código fuente a producción con GitLab CI

Objetivo

Dockerizar aplicaciones Flask generadas con IA y configurar pipeline CI/CD que maneje: build, tests, deployment a staging, y deployment a producción usando el container registry de GitLab.

Requisitos

  • Docker instalado localmente
  • Acceso a GitLab institucional (gitlab.unach.cl)
  • Repositorio configurado con estructura de ramas (main, staging, develop)
  • Container Registry habilitado en el proyecto GitLab

Dockerfile multi-stage

Crear Dockerfile en la raíz del proyecto:

# Stage 1: Builder
FROM python:3.12-slim AS builder

WORKDIR /app

# Instalar dependencias
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Stage 2: Production
FROM python:3.12-slim AS production

WORKDIR /app

# Copiar dependencias del builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages

# Copiar código de la aplicación
COPY . .

# Usuario no root para seguridad
RUN useradd --create-home appuser && \
    chown -R appuser:appuser /app
USER appuser

# Variables de entorno
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV FLASK_APP=run.py

# Exponer puerto
EXPOSE 5000

# Usar gunicorn como servidor de producción
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "run:app"]

Archivo de dependencias

Crear requirements.txt:

flask>=3.0.0
gunicorn>=21.0.0
python-dotenv>=1.0.0

.dockerignore

Crear .dockerignore para optimizar build:

__pycache__
*.pyc
.git
.env
.env.*
venv/
venv
.venv/
tests/
docs/
*.md
README.md
.DS_Store

GitLab CI/CD

Crear .gitlab-ci.yml en la raíz del proyecto:

stages:
  - build
  - test
  - deploy-staging
  - deploy-production

variables:
  IMAGE_NAME: $CI_REGISTRY_IMAGE:pivot
  DOCKER_DRIVER: overlay2

.build:
  &build
  image: docker:24-dind
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build -t $IMAGE_NAME:$CI_COMMIT_SHA .
    - docker build -t $IMAGE_NAME:latest .
    - docker push $IMAGE_NAME:$CI_COMMIT_SHA
    - docker push $IMAGE_NAME:latest
  after_script:
    - docker logout $CI_REGISTRY || true

build:
  <<: *build
  stage: build
  only:
    - develop
    - staging
    - main

test:
  stage: test
  image: python:3.12-slim
  before_script:
    - pip install -r requirements.txt
    - pip install pytest pytest-flask
  script:
    - pytest tests/ -v --junitxml=report.xml
  coverage: '/TOTAL.*\s+(\d+%)$/'
  artifacts:
    when: always
    reports:
      junit: report.xml
  only:
    - develop
    - staging
    - main

deploy-staging:
  <<: *build
  stage: deploy-staging
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker pull $IMAGE_NAME:$CI_COMMIT_SHA
    - |
      docker-compose -f docker-compose.staging.yml down || true
      docker-compose -f docker-compose.staging.yml up -d
    - docker logout $CI_REGISTRY || true
  environment:
    name: staging
    url: https://pivot-staging.unach.cl
  only:
    - staging

deploy-production:
  <<: *build
  stage: deploy-production
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker pull $IMAGE_NAME:$CI_COMMIT_SHA
    - |
      docker-compose -f docker-compose.production.yml down || true
      docker-compose -f docker-compose.production.yml up -d
    - docker logout $CI_REGISTRY || true
  environment:
    name: production
    url: https://pivot.unach.cl
  when: manual
  only:
    - main

Docker Compose para producción

Crear docker-compose.production.yml:

version: "3.9"

services:
  app:
    image: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}
    container_name: pivot-app
    restart: unless-stopped
    ports:
      - "5000:5000"
    environment:
      - APP_ENV=production
      - FLASK_ENV=production
      - DATABASE_URL=${DATABASE_URL}
      - SECRET_KEY=${SECRET_KEY}
      - JWT_SECRET=${JWT_SECRET}
      - ALLOWED_DTI_EMAILS=${ALLOWED_DTI_EMAILS}
      - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
      - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
    env_file:
      - .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M

  nginx:
    image: nginx:alpine
    container_name: pivot-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/production.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      - app
    healthcheck:
      test: ["CMD", "nginx", "-t"]
      interval: 30s
      timeout: 5s
      retries: 3

networks:
  default:
    name: pivot-network

Crear configuración NGINX nginx/production.conf:

upstream pivot_app {
    server app:5000;
}

server {
    listen 80;
    server_name pivot.unach.cl;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name pivot.unach.cl;

    ssl_certificate /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    client_max_body_size 10M;

    location / {
        proxy_pass http://pivot_app;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 120s;
        proxy_connect_timeout 120s;
    }

    location /health {
        proxy_pass http://pivot_app/health;
        access_log off;
    }
}

Docker Compose para staging

Crear docker-compose.staging.yml:

version: "3.9"

services:
  app:
    image: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}
    container_name: pivot-staging-app
    restart: unless-stopped
    ports:
      - "5001:5000"
    environment:
      - APP_ENV=staging
      - FLASK_ENV=staging
      - DATABASE_URL=${STAGING_DATABASE_URL}
      - SECRET_KEY=${STAGING_SECRET_KEY}
      - JWT_SECRET=${STAGING_JWT_SECRET}
      - ALLOWED_DTI_EMAILS=${ALLOWED_DTI_EMAILS}
      - GOOGLE_CLIENT_ID=${STAGING_GOOGLE_CLIENT_ID}
      - GOOGLE_CLIENT_SECRET=${STAGING_GOOGLE_CLIENT_SECRET}
    env_file:
      - .env.staging

networks:
  default:
    name: pivot-staging-network

Variables de entorno en GitLab

Configurar en GitLab: Settings → CI/CD → Variables

Staging

VariableDescripción
STAGING_DATABASE_URLURL de base de datos staging
STAGING_SECRET_KEYSecret key para staging
STAGING_JWT_SECRETJWT secret para staging
STAGING_GOOGLE_CLIENT_IDGoogle OAuth Client ID staging
STAGING_GOOGLE_CLIENT_SECRETGoogle OAuth Client Secret staging

Producción

VariableDescripción
DATABASE_URLURL de base de datos producción
SECRET_KEYSecret key para producción
JWT_SECRETJWT secret para producción
GOOGLE_CLIENT_IDGoogle OAuth Client ID producción
GOOGLE_CLIENT_SECRETGoogle OAuth Client Secret producción

Nunca hardcodear credenciales en el código.

Build local

# Construir imagen
docker build -t pivot:latest .

# Ejecutar localmente
docker run -d --name pivot -p 5000:5000 --env-file .env pivot:latest

# Ver logs
docker logs -f pivot

# Verificar
curl http://localhost:5000/health

# Con docker-compose
docker-compose -f docker-compose.staging.yml up -d

Referencia de imagen en el registry

Una vez que el pipeline ejecuta, la imagen queda disponible en:

gitlab.unach.cl/dti/pivot:<commit-sha>
gitlab.unach.cl/dti/pivot:latest

Para hacer pull manualmente:

# Login al registry
docker login gitlab.unach.cl

# Pull imagen
docker pull gitlab.unach.cl/dti/pivot:latest

# Tag para uso local
docker tag gitlab.unach.cl/dti/pivot:latest pivot:latest

Siguiente paso

Con Docker configurado, revisar Contributing para establecer flujo de branching y PRs.

Referencias