Financial Infrastructure · DevOps · Boston

Engineering inside
financial services.

Ten years across private equity, retail, and asset management. Real technical experience covering infrastructure, cloud, security, and trading systems. Written plainly to help other engineers navigate this world.

Michael Harlow
Michael Harlow // sys.ghost  ·  Boston, MA
☕ Buy me a coffee
Latest post
Every engineering org I talk to is running AI agents somewhere in production now. Almost none of them have figured out how to operate them the way we operate everything else that touches customer data.
Jul 14, 2026 · 11 min read
Read →
Latest post
Every engineering org I talk to is running AI agents somewhere in production now. Almost none of them have figured out how to operate them the way we operate everything else that touches customer data.
Jul 14, 2026 · 11 min read
Read →
All posts

Archive

We scheduled a Technology Connect event for our engineering team and built a lineup of games around AI and infrastructure topics. Here's how we put it together and what I'd do differently.
← Back to posts
DevOps Apr 13, 2026 · 18 min read

Building a Containerized Flask App That Uploads to S3, Deployed to OpenShift via Helm

Building a Containerized Flask App That Uploads to S3, Deployed to OpenShift via Helm

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')
AWS
SECRETKEY = os.environ.get('AWSSECRETACCESSKEY')

if not S3BUCKETNAME:
raise RuntimeError('S3BUCKETNAME environment variable is required')

s3client = boto3.client(
's3',
region
name=AWSREGION,
aws
accesskeyid=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.content
type or 'application/octet-stream'},
)
logger.info(f"Uploaded {file.filename} to s3://{S3BUCKETNAME}/{objectkey}")
return jsonify({'key': object
key, '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 AWS
SECRETACCESSKEY=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 AWS
SECRETACCESSKEY=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.

Found this useful?
☕ Buy Michael a Coffee
← More posts

Hey, I'm Michael Harlow.

Senior Systems Engineer · Boston, MA · Writing as sys.ghost

I have spent over a decade building and maintaining infrastructure at the intersection of technology and financial services. My career has taken me through three distinct sectors -- technology, private equity, and asset management -- and each one changed how I think about what reliable infrastructure actually requires.

I started in general IT, which is where most engineers who did not go straight into software end up. Data centers, networking, on-call rotations, learning to label cables properly because unlabeled cables are a promise that someone else will suffer later. The work taught me that almost every sophisticated system is, one layer down, a collection of unglamorous fundamentals that either hold or do not. I still believe that. I still label everything.

Private equity came next, and it was a different world. The infrastructure stakes there are less about uptime and more about data integrity. When deal teams are making acquisition decisions based on data you are responsible for, and when a due diligence process has a hard deadline that does not move regardless of what broke overnight, your relationship with reliability changes. A wrong number in an LP report does not cause an immediate incident. It causes a conversation in a partner meeting six weeks later, and by then you need to reconstruct what happened from imperfect records. I became obsessive about data provenance in PE and I have not stopped.

For the past several years I have been in asset management, supporting trading and investment operations infrastructure. This is the environment I find most technically interesting. The compliance requirements are demanding, the legacy systems have long institutional memories, and the tolerance for operational errors is genuinely low -- not just in terms of business impact, but in terms of regulatory consequence. When markets are open, there is no fixing it after the weekend.

I started Packet & Profit in January 2026 because I kept looking for the kind of writing I wanted to read and finding it mostly did not exist. There is a lot of content for engineers online. There is much less written by engineers working specifically inside regulated financial services firms, being honest about what that actually involves day to day. The compliance conversations, the legacy constraints, the incident management in front of stakeholders who measure downtime in dollars per minute. That is what I write about here.

Outside of work I have been running a Saturday morning robotics course at my local YMCA for kids aged 10 to 14. It is one of the better decisions I have made.

Certifications

Red Hat Certified Engineer (RHCE)
Certified Kubernetes Administrator (CKA)
AWS Solutions Architect -- Associate
CompTIA Security+
HashiCorp Vault Associate

My Stack

RHEL / Ubuntu
Kubernetes
OpenShift
Terraform
Ansible
Prometheus
Grafana
Python / Bash
AWS / Azure
Cisco / Palo Alto
PostgreSQL
Redis
HashiCorp Vault
Fluent Bit
Helm
ArgoCD

Career

2022 -- Present
Senior Systems Engineer, Asset Management -- Boston, MA
Leading infrastructure for trading operations and investment management systems. Responsibilities span network security, cloud migration strategy, Kubernetes platform engineering, and incident response. Deeply involved in T+1 settlement infrastructure work and the shift from overnight batch processing to near-real-time event-driven architecture.
2018 -- 2022
Systems Engineer, Private Equity -- Boston, MA
Built and maintained data infrastructure supporting deal teams, portfolio monitoring, and investor reporting. Managed infrastructure through multiple due diligence cycles with hard deadlines and high data integrity requirements. Led a major data platform migration from on-premises to cloud-hosted infrastructure, including security controls satisfying LP and regulatory requirements.
2015 -- 2018
Infrastructure Engineer, Retail Technology
Supported inventory management, real-time pricing, and supply chain integration systems across a high-SKU retail environment. Operated under peak load conditions where scale was a concrete engineering problem rather than an abstract one. Built out monitoring and alerting infrastructure from scratch and managed a full data center relocation.
2013 -- 2015
IT Engineer, Technology Sector
Established the professional fundamentals: data center operations, network infrastructure, endpoint management, and the on-call rotations that teach you more about system fragility than any textbook. Developed an appreciation for cable labeling that has never left me.

Get in Touch

If you are an engineer working in financial services, curious about the career path, or have a question about something I have written, I would genuinely like to hear from you. Use the and I will get back to you. If something here has been useful, a coffee is always appreciated.

A note on anonymity: I write under my own name but keep my current employer private. The financial services industry is small, the regulatory environment is real, and I want to write honestly without those constraints. All incidents and case studies on this site are anonymised. The technical content is real; identifying details are not.
Get in touch

Contact

Whether you are an engineer in financial services, have a question about something I have written, or just want to say hello - feel free to reach out. I read everything.

Powered by Resend · No spam, ever

Legal

Privacy Policy

Last updated: April 2026

This policy explains what information Packet & Profit collects when you visit this site, how it is used, and what choices you have.

Information We Collect

We do not require you to create an account or provide personal information to read this blog. The only personal information we collect is what you voluntarily submit through the contact form: your name, email address, and message. This information is transmitted via Resend and used solely to respond to your enquiry.

Google AdSense and Advertising

This site uses Google AdSense to display advertisements. Google AdSense uses cookies and similar tracking technologies to serve ads based on your prior visits to this and other websites. This means Google may use information about your visits to this site to show you personalised ads on other sites across the web.

You can opt out of personalised advertising by visiting Google Ads Settings, aboutads.info, or optout.networkadvertising.org. See Google advertising policies for more.

Cookies

This site uses a single first-party cookie to remember your theme preference (light or dark mode). This cookie contains no personal information. Third-party cookies may be set by Google AdSense for advertising purposes as described above.

Analytics

This site does not currently use any analytics platform beyond what Vercel provides as part of its standard hosting service (aggregated, anonymised traffic data).

Contact Form

When you submit the contact form, your name, email address, subject, and message are transmitted to the blog author via Resend. This data is not stored by this site and is not shared with any third party beyond Resend. See Resend's privacy policy for details.

Third-Party Links

Posts on this site may link to external websites. We are not responsible for the privacy practices or content of those sites.

Your Rights

If you have submitted a message via the contact form and would like that information removed, or if you have any questions about this policy, please use the contact form to get in touch.

Changes to This Policy

We may update this policy from time to time. The date at the top of this page reflects when it was last revised.

Legal

Terms of Service

Last updated: April 2026

By accessing and using Packet & Profit (packetandprofit.com), you agree to be bound by these Terms of Service. If you do not agree, please do not use this site.

Use of Content

All written content, illustrations, and code examples published on this site are the original work of Michael Harlow unless otherwise stated. You are welcome to share links to posts and quote brief excerpts (with attribution), but you may not reproduce full articles, copy content to other websites, or use the content for commercial purposes without written permission.

No Professional Advice

Content published on this site reflects personal opinions and professional experience. It is provided for informational and educational purposes only. Nothing on this site constitutes financial, investment, legal, or professional advice of any kind. See the for more detail.

Third-Party Links

This site may contain links to third-party websites. These links are provided for convenience and do not constitute an endorsement of the linked site or its content. We have no control over and accept no responsibility for external sites.

Advertising

This site participates in Google AdSense, which displays advertisements from third-party advertisers. The presence of an advertisement does not constitute an endorsement of the advertiser's products or services. Ad content is determined by Google based on the content of this site and your browsing history.

Accuracy of Information

While we make every effort to ensure the accuracy of information published on this site, technology and financial markets change rapidly. Information that was accurate at the time of publication may become outdated. We do not warrant the completeness, accuracy, or timeliness of any content on this site.

Limitation of Liability

To the fullest extent permitted by law, Packet & Profit and its author shall not be liable for any direct, indirect, incidental, or consequential damages arising from your use of, or inability to use, this site or its content.

Changes to These Terms

We reserve the right to update these terms at any time. Continued use of the site following any changes constitutes your acceptance of the revised terms. The date at the top of this page reflects the most recent revision.

Contact

If you have questions about these terms, please use the .

Legal

Disclaimer

Last updated: April 2026

Packet & Profit is a personal blog written by Michael Harlow, a Systems Engineer based in Boston, MA. The views expressed here are entirely his own and do not represent those of any employer, client, or organisation he is affiliated with.

Not Financial or Investment Advice

This site discusses financial services technology, investment management infrastructure, and related engineering topics from a technical practitioner's perspective. Nothing published here is financial advice, investment advice, or a recommendation to buy, sell, or hold any security, asset, or financial instrument. The author is not a registered financial adviser, broker, or investment professional.

Content that references financial markets, trading systems, or investment firms is provided for technical and educational context only. Any figures, case studies, or examples are illustrative and should not be relied upon for financial decisions.

Not Legal or Professional Advice

Nothing on this site constitutes legal, compliance, regulatory, or professional advice. Readers should consult qualified professionals for advice specific to their circumstances.

Professional Experience

Posts on this site draw on the author's professional experience in systems engineering across private equity, retail technology, and asset management. Specific details about employers, clients, projects, and colleagues have been anonymised or generalised. Any resemblance to specific organisations is incidental.

Accuracy

The author makes reasonable efforts to ensure published information is accurate at the time of writing. The technology and financial services landscape changes quickly. Readers should verify any technical or regulatory information against current primary sources before acting on it.

Affiliate Links and Advertising

This site displays advertisements through Google AdSense. The site may also contain links to tools, services, or products that the author uses or finds useful. These are not paid endorsements unless explicitly stated. The author's opinions are his own and are not influenced by advertisers.

Questions

For questions about anything on this site, please use the .

This site uses cookies for theme preferences and displays ads via Google AdSense, which may use cookies to personalise ads.