The Complete Journey
This is the single most important tutorial. You'll take a simple Flask application from your laptop all the way to production, with security checks at every step. You'll write a simple Flask app, create a hardened Dockerfile, build and scan locally, generate a Software Bill of Materials, sign the image with cryptographic signatures, test locally with docker-compose, deploy to Kubernetes, and finally verify everything works in production.
Time: 45 minutes end-to-end
Prerequisites include Docker installed, kubectl installed for deployment to Kubernetes, Helm installed for package management, and a container registry account such as Docker Hub, GCR, or similar services.
Step 1: Write a Simple Flask Application
Create a new directory and set up your Flask project:
mkdir myflaskapp && cd myflaskappCreate app.py:
#!/usr/bin/env python3from flask import Flask, jsonifyimport logging app = Flask(__name__) # Setup logginglogging.basicConfig(level=logging.INFO)logger = logging.getLogger(__name__) @app.route('/health', methods=['GET'])def health(): """Health check endpoint""" return jsonify({"status": "healthy"}), 200 @app.route('/api/version', methods=['GET'])def version(): """Return app version""" return jsonify({"version": "1.0.0", "app": "myflaskapp"}), 200 @app.route('/api/message', methods=['GET'])def message(): """Return a simple message""" return jsonify({"message": "Hello from CleanStart!"}), 200 if __name__ == '__main__': logger.info("Starting myflaskapp") app.run(host='0.0.0.0', port=5000, debug=False)Create requirements.txt:
Flask==2.3.0Test locally (optional):
pip install -r requirements.txtpython app.py # In another terminal:curl http://localhost:5000/health# {"status":"healthy"}Step 2: Create Multi-Stage Dockerfile with CleanStart Base
A proper Dockerfile uses multi-stage builds to separate build tooling from the final runtime image.
Create Dockerfile:
# Stage 1: Builder (has all dev tools)FROM cleanstart/python:3.11-dev as builder WORKDIR /build # Copy requirements and install into /usr/localCOPY requirements.txt .RUN pip install --user --no-cache-dir -r requirements.txt # Stage 2: Runtime (tiny, production-ready)FROM cleanstart/python:3.11-prod # Set working directoryWORKDIR /app # Copy installed packages from builderCOPY --from=builder /root/.local /root/.local # Update PATH to use user-installed packagesENV PATH=/root/.local/bin:$PATH \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 # Copy application codeCOPY app.py . # Create non-root user (CleanStart default: UID 65532)USER 65532 # Expose portEXPOSE 5000 # Health checkHEALTHCHECK --interval=10s --timeout=3s --start-period=5s \ CMD curl -f http://localhost:5000/health || exit 1 # Start applicationCMD ["python", "-m", "flask", "--app", "app", "run", "--host", "0.0.0.0", "--port", "5000"]Why this Dockerfile is good: This Dockerfile follows production-ready best practices in every way. The multi-stage build approach ensures the final image contains no compiler or build tools, reducing its size from approximately 2GB to around 350MB. Using CleanStart base images provides pre-scanned, signed, and hardened foundations that have already been verified for security vulnerabilities. The non-root user (UID 65532) prevents privilege escalation attacks that could give attackers root access. The built-in health check enables orchestrators like Kubernetes to detect and respond to failures automatically. The minimal surface area, containing only Flask and its dependencies, reduces attack vectors and security maintenance overhead.
Step 3: Build Docker Image Locally
# Build the imagedocker build -t myflaskapp:1.0.0 . # Expected output:# [+] Building 45.2s (11/11) FINISHED# => => naming to docker.io/library/myflaskapp:1.0.0 # List the image (check size)docker images | grep myflaskapp# myflaskapp 1.0.0 a1b2c3d4 10 minutes ago 350MBStep 4: Scan for Vulnerabilities
Use Grype to detect known vulnerabilities in your image.
# Install Grype (if not already installed)curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin # Scan the imagegrype myflaskapp:1.0.0 --fail-on high # Expected output (if no vulnerabilities):# 0 critical# 0 high# 0 medium# 0 low# All vulnerabilities have been remediated!If vulnerabilities are found, show detailed reports and update dependencies as needed.
Step 5: Generate SBOM (Software Bill of Materials)
Create a record of every component in your image.
# Install Syft (if not already installed)curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin # Generate SBOMsyft myflaskapp:1.0.0 --output cyclonedx-json > sbom.json # View the SBOM (human-readable)jq '.components[] | {name, version}' sbom.json | head -20 # Store the SBOMcp sbom.json sbom-myflaskapp-1.0.0.jsongit add sbom-myflaskapp-1.0.0.jsongit commit -m "feat: add SBOM for myflaskapp:1.0.0"Step 6: Sign the Image with Cosign
Prove that this image came from your trusted build system.
# Install Cosigncurl -sSL https://github.com/sigstore/cosign/releases/download/v2.0.0/cosign-linux-amd64 \ -o /usr/local/bin/cosign && chmod +x /usr/local/bin/cosign # Sign the image (using keyless signing for testing)COSIGN_EXPERIMENTAL=1 cosign sign --yes myflaskapp:1.0.0 # Expected output:# WARNING: experimental mode enabled. DO NOT use in production.# Signing myflaskapp:1.0.0# WARNING: Changing signing key to: ...# Successfully signed myflaskapp:1.0.0Verify the signature:
COSIGN_EXPERIMENTAL=1 cosign verify --insecure-ignore-tlog myflaskapp:1.0.0 # Expected output:# The following checks were performed on each of these signatures:# - The cosign claims were validated# - Existence of the claims in the transparency log was verified offlineStep 7: Run Locally with Docker
Test that the image actually works before deploying anywhere.
# Run the containerdocker run -p 5000:5000 myflaskapp:1.0.0 # Expected output:# WARNING in flask.app: This is a development server. Do not use it in production.# * Running on http://0.0.0.0:5000 # In another terminal, test the endpointscurl http://localhost:5000/health# {"status":"healthy"} curl http://localhost:5000/api/version# {"version":"1.0.0","app":"myflaskapp"} curl http://localhost:5000/api/message# {"message":"Hello from CleanStart!"} # Stop the container# Press Ctrl+C in the running containerStep 8: Deploy with Docker-Compose
Create a multi-service stack with Flask app, PostgreSQL, and Redis.
Create docker-compose.yml:
version: '3.8' services: # Flask Application app: image: myflaskapp:1.0.0 ports: - "5000:5000" environment: - DATABASE_URL=postgresql://myuser:mypass@db:5432/mydb - REDIS_URL=redis://cache:6379/0 - FLASK_ENV=production depends_on: db: condition: service_healthy cache: condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5000/health"] interval: 10s timeout: 3s retries: 3 start_period: 5s networks: - mynet restart: unless-stopped security_opt: - no-new-privileges:true read_only_root_filesystem: true tmpfs: - /tmp - /var/tmp # PostgreSQL Database db: image: postgres:15-alpine environment: - POSTGRES_USER=myuser - POSTGRES_PASSWORD=mypass - POSTGRES_DB=mydb volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U myuser"] interval: 10s timeout: 3s retries: 3 networks: - mynet restart: unless-stopped security_opt: - no-new-privileges:true # Redis Cache cache: image: redis:7-alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 3 networks: - mynet restart: unless-stopped security_opt: - no-new-privileges:true volumes: db_data: networks: mynet: driver: bridgeCreate .env file:
DATABASE_URL=postgresql://myuser:mypass@db:5432/mydbREDIS_URL=redis://cache:6379/0FLASK_ENV=productionDeploy with docker-compose:
# Start all servicesdocker-compose up -d # Check statusdocker-compose ps # Test the appcurl http://localhost:5000/health# {"status":"healthy"} # View logsdocker-compose logs app # Stop all servicesdocker-compose downStep 9: Push to Container Registry
Upload your image so it can be deployed anywhere.
# Option A: Docker Hubdocker login docker.iodocker tag myflaskapp:1.0.0 username/myflaskapp:1.0.0docker push username/myflaskapp:1.0.0 # Option B: Google Container Registrygcloud auth configure-docker gcr.iodocker tag myflaskapp:1.0.0 gcr.io/my-project/myflaskapp:1.0.0docker push gcr.io/my-project/myflaskapp:1.0.0 # Option C: GitHub Container Registryecho $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdindocker tag myflaskapp:1.0.0 ghcr.io/myusername/myflaskapp:1.0.0docker push ghcr.io/myusername/myflaskapp:1.0.0 # Verify it's in the registrydocker pull username/myflaskapp:1.0.0Step 10: Deploy to Kubernetes
Deploy to a Kubernetes cluster.
Create deployment.yaml:
apiVersion: apps/v1kind: Deploymentmetadata: name: myflaskapp labels: app: myflaskappspec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: myflaskapp template: metadata: labels: app: myflaskapp annotations: container.apparmor.security.beta.kubernetes.io/app: runtime/default spec: serviceAccountName: default securityContext: runAsNonRoot: true runAsUser: 65532 runAsGroup: 65532 fsGroup: 65532 seccompProfile: type: RuntimeDefault containers: - name: app image: username/myflaskapp:1.0.0 imagePullPolicy: IfNotPresent ports: - containerPort: 5000 protocol: TCP env: - name: FLASK_ENV value: production - name: DATABASE_URL valueFrom: secretKeyRef: name: myflaskapp-secrets key: database-url resources: requests: memory: "128Mi" cpu: "100m" limits: memory: "256Mi" cpu: "500m" livenessProbe: httpGet: path: /health port: 5000 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /health port: 5000 initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL readOnlyRootFilesystem: true runAsNonRoot: true volumeMounts: - name: tmp mountPath: /tmp - name: var-tmp mountPath: /var/tmp volumes: - name: tmp emptyDir: {} - name: var-tmp emptyDir: {} affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: app operator: In values: - myflaskapp topologyKey: kubernetes.io/hostname ---apiVersion: v1kind: Servicemetadata: name: myflaskappspec: type: LoadBalancer selector: app: myflaskapp ports: - protocol: TCP port: 80 targetPort: 5000Deploy to Kubernetes:
# Create namespacekubectl create namespace myflaskapp # Create secrets (if needed)kubectl create secret generic myflaskapp-secrets \ --from-literal=database-url=postgresql://user:pass@db:5432/db \ -n myflaskapp # Apply the deploymentkubectl apply -f deployment.yaml -n myflaskapp # Check rollout statuskubectl rollout status deployment/myflaskapp -n myflaskapp # Get the service URLkubectl get service myflaskapp -n myflaskapp # Test the appcurl http://<EXTERNAL-IP>/health# {"status":"healthy"} # View logskubectl logs -l app=myflaskapp -n myflaskapp # Verify security contextkubectl exec -it $(kubectl get pod -l app=myflaskapp -n myflaskapp -o jsonpath='{.items[0].metadata.name}') -- whoami# Output should be: 65532 (non-root user)Step 11: Deploy with Helm (Cloud-Native)
Helm packages Kubernetes deployments for reusability.
Create helm chart structure:
helm create myflaskapp-chartThis generates a standard Helm chart with Chart.yaml defining metadata, values.yaml containing default configuration, and templates/ directory with Kubernetes manifests.
Update values.yaml:
image: repository: username/myflaskapp tag: "1.0.0" pullPolicy: IfNotPresent replicaCount: 3 service: type: LoadBalancer port: 80 targetPort: 5000 resources: limits: cpu: 500m memory: 256Mi requests: cpu: 100m memory: 128Mi autoscaling: enabled: true minReplicas: 3 maxReplicas: 10 targetCPUUtilizationPercentage: 80Deploy with Helm:
# Create namespacekubectl create namespace myflaskapp # Install charthelm install myflaskapp ./myflaskapp-chart -n myflaskapp # Verify deploymenthelm list -n myflaskappkubectl get pods -n myflaskapp # Update chart (new version)helm upgrade myflaskapp ./myflaskapp-chart -n myflaskapp \ --set image.tag=1.1.0 # Rollback if neededhelm rollback myflaskapp 1 -n myflaskapp # Uninstallhelm uninstall myflaskapp -n myflaskappStep 12: Verify Production Deployment
Final checks to ensure everything is secure and working.
# 1. Check image signaturesCOSIGN_EXPERIMENTAL=1 cosign verify --insecure-ignore-tlog myflaskapp:1.0.0 # 2. Check SBOM is presentsyft myflaskapp:1.0.0 --output cyclonedx-json | jq '.components | length'# Should show hundreds of components # 3. Scan production image for vulnerabilitiesgrype myflaskapp:1.0.0# Should show 0 critical, 0 high vulnerabilities # 4. Check running container securitykubectl get pods -n myflaskapp -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.securityContext}{"\n"}{end}' # 5. Verify health checks are workingkubectl describe deployment myflaskapp -n myflaskapp | grep -A5 "Liveness\|Readiness" # 6. Test the applicationAPP_URL=$(kubectl get service myflaskapp -n myflaskapp -o jsonpath='{.status.loadBalancer.ingress[0].ip}')curl http://$APP_URL/healthcurl http://$APP_URL/api/version # 7. Check logs for errorskubectl logs -l app=myflaskapp -n myflaskapp --tail=50 # 8. Verify no privilege escalation is possiblekubectl exec -it $(kubectl get pod -l app=myflaskapp -n myflaskapp -o jsonpath='{.items[0].metadata.name}') -- whoami# Output should be: 65532 (non-root user) # 9. Check that root filesystem is read-onlykubectl exec -it $(kubectl get pod -l app=myflaskapp -n myflaskapp -o jsonpath='{.items[0].metadata.name}') -- touch /test.txt# Should fail: Read-only file systemComplete Checklist
After finishing all steps, verify completion of the following items: Flask app is written and tested locally, Dockerfile uses multi-stage build and CleanStart base, image builds without errors, Grype scan shows zero critical and zero high vulnerabilities, SBOM is generated and stored, image is signed with Cosign, Docker-compose stack runs all services in healthy status, image is pushed to registry, Kubernetes deployment runs three replicas, all endpoints respond correctly, health checks are working, security context is enforced with non-root user, read-only filesystem, and no new privileges, and logs are clean with no errors.
The Bottom Line
The journey from code to production requires attention to detail at every step. Your Dockerfile matters and should use multi-stage builds with CleanStart base images and non-root users. Scanning with Grype catches vulnerabilities before production. SBOMs track all components in your image. Signing with Cosign proves image authenticity. Docker-compose allows testing multi-service stacks locally. Kubernetes enables production deployments with automatic health management and security controls. Every step has a purpose and skipping security checks leads to compromised systems.
Next Steps: Deepen Your Secure Deployment Knowledge
Foundation — To understand the concepts behind secure deployment, read Container Image Fundamentals for what images are and how they work, Container Security Best Practices for principles applicable to every deployment, and What is Supply Chain Security? for why every step in the chain matters.
Deep dives — To learn the technologies you just used, explore Build Stage Security for secure Dockerfiles and builds, The Continuous Trust Loop for automated vulnerability remediation, and Strip-Down vs Source-Built for image composition approaches.
Practical examples — To apply patterns to your services, see Docker-Compose Examples for multi-service deployments locally and Getting Started: Python/Node.js/Go/Java for language-specific deployment guides.
Operations at scale — For managing secure images in production, consult Helm Charts & Kubernetes for deploying at scale securely, Upgrade & Patching Playbook for keeping images patched, and Multi-Cloud Registry Operations for managing images across clouds.
