This post walks through building a working Python Flask application that accepts file uploads and stores them in AWS S3, then containers it, writes a Helm chart for it, and deploys the whole thing to OpenShift. Every command is included in order.
What you need before starting: Python 3.11+, Docker, the OpenShift CLI (oc), Helm 3, an AWS account with S3 access, and an OpenShift cluster you can push to.
What we're building
A simple Flask web service with two endpoints: POST /upload accepts a file and uploads it to an S3 bucket, returning the object key. GET /health returns a health check. Simple enough to follow the full path from code to deployed container without getting lost in application complexity.
Step 1: The Flask application
Create the project directory:
mkdir flask-s3-uploader && cd flask-s3-uploader
Create the application file:
# app.py
import os
import uuid
import boto3
from flask import Flask, request, jsonify
from botocore.exceptions import ClientError
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(_name_)
app = Flask(_name_)
# Configuration from environment variables - never hardcode credentials
AWSREGION = os.environ.get('AWSREGION', 'us-east-1')
S3BUCKETNAME = os.environ.get('S3BUCKETNAME')
AWSACCESSKEY = os.environ.get('AWSACCESSKEYID')
AWSSECRETKEY = os.environ.get('AWSSECRETACCESSKEY')
if not S3BUCKETNAME:
raise RuntimeError('S3BUCKETNAME environment variable is required')
s3client = boto3.client(
's3',
regionname=AWSREGION,
awsaccesskeyid=AWSACCESSKEY,
awssecretaccesskey=AWSSECRET_KEY,
)
@app.route('/health')
def health():
return jsonify({'status': 'ok', 'bucket': S3BUCKETNAME})
@app.route('/upload', methods=['POST'])
def upload():
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'Empty filename'}), 400
# Generate a unique key so uploads never overwrite each other
extension = os.path.splitext(file.filename)[1]
object_key = f"uploads/{uuid.uuid4()}{extension}"
try:
s3client.uploadfileobj(
file,
S3BUCKETNAME,
objectkey,
ExtraArgs={'ContentType': file.contenttype or 'application/octet-stream'},
)
logger.info(f"Uploaded {file.filename} to s3://{S3BUCKETNAME}/{objectkey}")
return jsonify({'key': objectkey, 'bucket': S3BUCKETNAME}), 201
except ClientError as e:
logger.error(f"S3 upload failed: {e}")
return jsonify({'error': 'Upload failed'}), 500
if _name == 'main_':
app.run(host='0.0.0.0', port=8080)
```
Create requirements.txt:
flask==3.0.3
boto3==1.34.84
gunicorn==22.0.0
Test it locally first:
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
export S3BUCKETNAME=your-bucket-name
export AWSACCESSKEYID=your-key
export AWSSECRETACCESSKEY=your-secret
python app.py
```
In another terminal, test the health endpoint:
curl http://localhost:8080/health
# {"bucket":"your-bucket-name","status":"ok"}
Test an upload:
curl -X POST http://localhost:8080/upload \
-F "file=@/path/to/any/file.txt"
# {"bucket":"your-bucket-name","key":"uploads/abc123-uuid.txt"}
Step 2: The Dockerfile
We want a production container: small image, non-root user, gunicorn as the WSGI server (not Flask's dev server), and no unnecessary packages.
# Dockerfile
FROM python:3.11-slim
# Create non-root user - required for OpenShift's restricted SCC
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app
# Install dependencies first (better layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app.py .
# OpenShift assigns a random UID at runtime. The GID will still be 0 (root group).
# This permission allows that random UID to write to /app if needed.
RUN chown -R appuser:appgroup /app && chmod -R 755 /app
USER appuser
EXPOSE 8080
# Gunicorn: 2 workers per CPU core is a reasonable starting point
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--timeout", "60", "app:app"]
```
Build and test locally:
docker build -t flask-s3-uploader:latest .
docker run -p 8080:8080 \
-e S3BUCKETNAME=your-bucket \
-e AWSACCESSKEYID=your-key \
-e AWSSECRETACCESSKEY=your-secret \
flask-s3-uploader:latest
```
The app should behave identically to the local Python run.
Tag and push to your registry. I'll use a generic registry path (substitute your actual registry):
docker tag flask-s3-uploader:latest registry.example.com/myteam/flask-s3-uploader:1.0.0
docker push registry.example.com/myteam/flask-s3-uploader:1.0.0
Step 3: The Helm chart
Helm charts are a structured way to package Kubernetes manifests with templated values. Instead of maintaining separate YAML files per environment, you write templates once and override values per deployment.
Create the chart scaffold:
helm create flask-s3-uploader
cd flask-s3-uploader
Helm creates a default structure. We'll rewrite it for our application. First, clear the default templates:
rm -rf templates/*
Create templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "flask-s3-uploader.fullname" . }}
labels:
{{- include "flask-s3-uploader.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "flask-s3-uploader.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "flask-s3-uploader.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
- name: S3_BUCKET_NAME
valueFrom:
secretKeyRef:
name: {{ include "flask-s3-uploader.fullname" . }}-secret
key: s3-bucket-name
- name: AWS_REGION
value: {{ .Values.aws.region | quote }}
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ include "flask-s3-uploader.fullname" . }}-secret
key: aws-access-key-id
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "flask-s3-uploader.fullname" . }}-secret
key: aws-secret-access-key
resources:
{{- toYaml .Values.resources | nindent 12 }}
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
capabilities:
drop: ["ALL"]
Create templates/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: {{ include "flask-s3-uploader.fullname" . }}
labels:
{{- include "flask-s3-uploader.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 8080
protocol: TCP
name: http
selector:
{{- include "flask-s3-uploader.selectorLabels" . | nindent 4 }}
Create templates/route.yaml. This is OpenShift-specific. It replaces Ingress for exposing the service externally:
{{- if .Values.route.enabled }}
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: {{ include "flask-s3-uploader.fullname" . }}
labels:
{{- include "flask-s3-uploader.labels" . | nindent 4 }}
spec:
to:
kind: Service
name: {{ include "flask-s3-uploader.fullname" . }}
port:
targetPort: http
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
{{- end }}
Create templates/secret.yaml, which stores AWS credentials as a Kubernetes Secret:
apiVersion: v1
kind: Secret
metadata:
name: {{ include "flask-s3-uploader.fullname" . }}-secret
labels:
{{- include "flask-s3-uploader.labels" . | nindent 4 }}
type: Opaque
stringData:
s3-bucket-name: {{ .Values.aws.bucketName | quote }}
aws-access-key-id: {{ .Values.aws.accessKeyId | quote }}
aws-secret-access-key: {{ .Values.aws.secretAccessKey | quote }}
A note on this: storing credentials in a Helm values file that gets committed to git is a bad idea. In production you'd use something like External Secrets Operator or Vault to inject these. For this walkthrough, we'll pass them via --set flags at deploy time so they never touch the values file.
Create templates/_helpers.tpl (the standard Helm helpers):
{{- define "flask-s3-uploader.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "flask-s3-uploader.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{ include "flask-s3-uploader.selectorLabels" . }}
{{- end }}
{{- define "flask-s3-uploader.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
```
Update values.yaml:
replicaCount: 2
image:
repository: registry.example.com/myteam/flask-s3-uploader
tag: "1.0.0"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
route:
enabled: true
aws:
region: us-east-1
bucketName: "" # pass via --set at deploy time
accessKeyId: "" # pass via --set at deploy time
secretAccessKey: "" # pass via --set at deploy time
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
```
Validate the chart before deploying:
helm lint .
helm template flask-uploader . \
--set aws.bucketName=test \
--set aws.accessKeyId=test \
--set aws.secretAccessKey=test
The second command renders the templates to stdout. Read through it and check that the manifests look right before touching the cluster.
Step 4: Deploy to OpenShift
Log in and create a namespace:
oc login https://your-cluster-api-url:6443 --token=your-token
oc new-project flask-s3-uploader
If your image registry requires authentication, create an image pull secret:
oc create secret docker-registry registry-creds \
--docker-server=registry.example.com \
--docker-username=your-username \
--docker-password=your-password \
-n flask-s3-uploader
oc secrets link default registry-creds --for=pull -n flask-s3-uploader
```
Deploy with Helm, passing credentials at the command line:
helm install flask-uploader . \
--namespace flask-s3-uploader \
--set aws.bucketName=your-actual-bucket \
--set aws.accessKeyId=your-actual-key \
--set aws.secretAccessKey=your-actual-secret \
--set image.repository=registry.example.com/myteam/flask-s3-uploader \
--set image.tag=1.0.0
Check the deployment:
oc get pods -n flask-s3-uploader
# NAME READY STATUS RESTARTS AGE
# flask-uploader-flask-s3-uploader-7d6b9c-xkp2 1/1 Running 0 45s
oc get route -n flask-s3-uploader
# NAME HOST/PORT PATH SERVICES
# flask-uploader-flask-s3-... flask-uploader-flask-s3-uploader.apps.example.com flask-uploader-...
```
Test the deployed endpoint:
ROUTE_HOST=$(oc get route flask-uploader-flask-s3-uploader -n flask-s3-uploader -o jsonpath='{.spec.host}')
curl https://$ROUTE_HOST/health
# {"bucket":"your-actual-bucket","status":"ok"}
curl -X POST https://$ROUTE_HOST/upload \
-F "file=@/path/to/testfile.txt"
# {"bucket":"your-actual-bucket","key":"uploads/some-uuid.txt"}
```
Step 5: The SCC issue you will almost certainly hit
OpenShift's default SCC (Security Context Constraint) is restricted. If your container tries to run as a specific UID, OpenShift will reject it. The securityContext: runAsNonRoot: true in the deployment spec handles this: it tells OpenShift you're happy to run as whatever UID it assigns.
If you see a pod stuck in CreateContainerConfigError:
oc describe pod <pod-name> -n flask-s3-uploader
Look for a line like container has runAsNonRoot and image will run as root. That means the base image defaults to root. Fix it in the Dockerfile by explicitly setting a non-root user (which we did with USER appuser).
If you still see issues, check which SCC the pod is getting:
oc get pod <pod-name> -o yaml | grep scc
For most Flask apps the restricted SCC is fine as long as you run as non-root.
Updating the deployment
When you push a new image version, upgrade with Helm:
docker build -t registry.example.com/myteam/flask-s3-uploader:1.0.1 .
docker push registry.example.com/myteam/flask-s3-uploader:1.0.1
helm upgrade flask-uploader . \
--namespace flask-s3-uploader \
--set image.tag=1.0.1 \
--set aws.bucketName=your-bucket \
--set aws.accessKeyId=your-key \
--set aws.secretAccessKey=your-secret
```
Helm will do a rolling update. The old pods stay up until the new ones pass their readiness probes.
To roll back if something goes wrong:
helm rollback flask-uploader 1 --namespace flask-s3-uploader
---
That's the full path: Flask app to container to Helm chart to running on OpenShift. The pattern scales to any Python service. Swap the S3 logic for whatever your application actually does. The Dockerfile, Helm chart structure, and OpenShift-specific bits (Route instead of Ingress, SCC awareness, non-root user) stay the same.