Containers Changed How We Ship Financial Software
Before we containerized our trading platforms, deployments were multi-hour ceremonies. A senior engineer would SSH into production servers, run a deploy script, and pray that the PHP version, the extension set, and the OS-level dependencies all matched what we tested against. Sometimes they didn't. In fintech, "sometimes" is not acceptable.
Docker eliminated the "works on my machine" problem entirely. Kubernetes took it further by giving us self-healing, auto-scaling infrastructure that can handle the traffic spikes that come with market volatility. When a major economic announcement drops and trading volume spikes 10x in 30 seconds, our platform scales automatically. That wasn't possible with manual deployments.
Here's how I build and operate containerized fintech platforms.
Docker: Building Production-Ready Images
Most Dockerfiles I see in the wild are bloated, insecure, and slow to build. For fintech, you need minimal images with no unnecessary packages, multi-stage builds to keep secrets out of the final image, and non-root execution.
# Stage 1: Install PHP dependencies
FROM composer:2.7 AS composer-deps
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--ignore-platform-reqs
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative
# Stage 2: Build frontend assets
FROM node:20-alpine AS frontend-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY resources/ resources/
COPY vite.config.js tailwind.config.js postcss.config.js ./
RUN npm run build
# Stage 3: Production image
FROM php:8.3-fpm-alpine AS production
# Install only what we need — no dev tools, no compilers
RUN apk add --no-cache \
postgresql-libs \
libzip \
icu-libs \
&& apk add --no-cache --virtual .build-deps \
postgresql-dev \
libzip-dev \
icu-dev \
&& docker-php-ext-install \
pdo_pgsql \
zip \
intl \
opcache \
bcmath \
&& apk del .build-deps
# OPcache configuration for production
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.interned_strings_buffer=16" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini
# Create non-root user
RUN addgroup -g 1000 -S appgroup \
&& adduser -u 1000 -S appuser -G appgroup
WORKDIR /var/www/html
# Copy application code
COPY /app/vendor ./vendor
COPY /app/public/build ./public/build
COPY . .
# Set ownership and permissions
RUN chown -R appuser:appgroup /var/www/html \
&& chmod -R 755 storage bootstrap/cache
USER appuser
EXPOSE 9000
HEALTHCHECK \
CMD php-fpm-healthcheck || exit 1
Key decisions here: Alpine-based for minimal attack surface. Build dependencies are installed and removed in the same layer. The application runs as a non-root user. OPcache has validate_timestamps=0 because we never modify code in a running container — we deploy new containers.
Docker Compose for Local Development
Your local environment should mirror production as closely as possible. I use Docker Compose with profiles to manage different service configurations.
# docker-compose.yml
services:
app:
build:
context: .
target: production
volumes:
- .:/var/www/html
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
- DB_HOST=db
- REDIS_HOST=redis
networks:
- fintech
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
- .:/var/www/html
depends_on:
- app
networks:
- fintech
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: fintech
POSTGRES_USER: fintech
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fintech"]
interval: 5s
timeout: 3s
retries: 5
networks:
- fintech
redis:
image: redis:7-alpine
command: redis-server --requirepass secret --maxmemory 256mb --maxmemory-policy allkeys-lfu
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "-a", "secret", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- fintech
queue-worker:
build:
context: .
target: production
command: php artisan queue:work --tries=3 --timeout=90 --memory=256
depends_on:
- db
- redis
environment:
- DB_HOST=db
- REDIS_HOST=redis
networks:
- fintech
scheduler:
build:
context: .
target: production
command: >
sh -c "while true; do php artisan schedule:run --no-interaction; sleep 60; done"
depends_on:
- db
- redis
networks:
- fintech
volumes:
pgdata:
networks:
fintech:
driver: bridge
Kubernetes: Production Deployment
Moving from Docker Compose to Kubernetes is where fintech teams often stumble. Let me walk through the manifests I use for production trading platforms.
Deployment with Rolling Updates
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: trading-platform
namespace: production
labels:
app: trading-platform
tier: backend
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # Zero downtime — never remove a pod before adding one
selector:
matchLabels:
app: trading-platform
template:
metadata:
labels:
app: trading-platform
spec:
serviceAccountName: trading-platform
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: php-fpm
image: registry.company.com/trading-platform:TAG
ports:
- containerPort: 9000
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "512Mi"
readinessProbe:
exec:
command:
- php-fpm-healthcheck
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec:
command:
- php-fpm-healthcheck
initialDelaySeconds: 15
periodSeconds: 20
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
volumeMounts:
- name: storage
mountPath: /var/www/html/storage/app
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
volumes:
- name: nginx-config
configMap:
name: nginx-config
- name: storage
persistentVolumeClaim:
claimName: app-storage
The maxUnavailable: 0 setting is non-negotiable for trading platforms. During a rolling update, Kubernetes adds a new pod before removing an old one, ensuring zero downtime. Combined with readiness probes, no traffic hits a pod until it's fully initialized.
Secrets Management
Never put secrets in ConfigMaps, environment variables in your manifests, or (obviously) in your codebase. I use Kubernetes Secrets with a secrets management tool like HashiCorp Vault or AWS Secrets Manager.
# k8s/sealed-secret.yaml — using Sealed Secrets for GitOps
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: app-secrets
namespace: production
spec:
encryptedData:
DB_PASSWORD: AgBr7s...encrypted...
REDIS_PASSWORD: AgCx9k...encrypted...
PSP_API_KEY: AgDw2m...encrypted...
PSP_SECRET: AgEv5n...encrypted...
JWT_SECRET: AgFt8p...encrypted...
For more dynamic secrets rotation, integrate with Vault:
# k8s/vault-injection.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: trading-platform
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "trading-platform"
vault.hashicorp.com/agent-inject-secret-db: "secret/data/production/database"
vault.hashicorp.com/agent-inject-template-db: |
{{- with secret "secret/data/production/database" -}}
DB_HOST={{ .Data.data.host }}
DB_PORT={{ .Data.data.port }}
DB_DATABASE={{ .Data.data.name }}
DB_USERNAME={{ .Data.data.username }}
DB_PASSWORD={{ .Data.data.password }}
{{- end }}
Vault injects secrets as files into the pod at runtime. The secrets never exist in etcd or in your YAML manifests.
Horizontal Pod Autoscaler
Trading platforms need to scale based on actual load, not fixed replica counts. During market hours, CPU usage climbs. During overnight maintenance windows, it drops. The HPA handles this automatically.
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: trading-platform-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: trading-platform
minReplicas: 3
maxReplicas: 15
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 75
- type: Pods
pods:
metric:
name: php_fpm_active_processes
target:
type: AverageValue
averageValue: "8"
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 3
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 min before scaling down
policies:
- type: Pods
value: 1
periodSeconds: 120
The scaleDown.stabilizationWindowSeconds: 300 is critical. Without it, the HPA would scale down immediately after a traffic spike, only to scale up again seconds later when the next spike hits. A 5-minute cooldown prevents this thrashing.
I also use a custom metric (php_fpm_active_processes) from our monitoring stack. CPU and memory alone don't tell the full story for PHP-FPM workloads — a pod can have low CPU but all its FPM workers busy, which means it can't handle more requests.
Network Policies
In fintech, pods should only communicate with the services they actually need. Network policies enforce this at the cluster level.
# k8s/network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: trading-platform-netpol
namespace: production
spec:
podSelector:
matchLabels:
app: trading-platform
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: nginx-ingress
ports:
- port: 80
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- port: 5432
- to:
- podSelector:
matchLabels:
app: redis
ports:
- port: 6379
- to:
# Allow DNS resolution
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- to:
# Allow outbound HTTPS for PSP callbacks
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 443
This policy says: the trading platform can only receive traffic from the ingress controller, can only talk to PostgreSQL, Redis, DNS, and external HTTPS endpoints. If a pod gets compromised, the attacker can't laterally move to other services in the cluster.
Queue Workers as Separate Deployments
Queue workers have different resource profiles and scaling requirements than web-serving pods. Deploy them separately.
# k8s/queue-worker.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: queue-worker
namespace: production
spec:
replicas: 2
selector:
matchLabels:
app: queue-worker
template:
spec:
containers:
- name: worker
image: registry.company.com/trading-platform:TAG
command: ["php", "artisan", "queue:work",
"--queue=deposits,withdrawals,commissions,default",
"--tries=3", "--timeout=120", "--memory=384"]
resources:
requests:
cpu: "500m"
memory: "384Mi"
limits:
cpu: "1500m"
memory: "512Mi"
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
Note the queue priority order: deposits,withdrawals,commissions,default. Financial transactions get processed before general background tasks. This is a simple but effective way to ensure deposit processing isn't delayed because a batch email job is hogging the workers.
Pod Disruption Budgets
During Kubernetes node maintenance or cluster upgrades, nodes get drained. Without a Pod Disruption Budget, all your replicas could be terminated simultaneously.
# k8s/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: trading-platform-pdb
namespace: production
spec:
minAvailable: 2
selector:
matchLabels:
app: trading-platform
This guarantees that at least 2 pods are always running, even during voluntary disruptions like node drains. For a trading platform, this is the difference between "planned maintenance" and "unplanned outage."
Key Takeaways
- Use multi-stage Docker builds to keep production images minimal and secure. Build dependencies, Node.js tooling, and Composer should never exist in your production image. Smaller images mean faster deploys and a smaller attack surface.
- Set
maxUnavailable: 0on rolling updates for zero-downtime deployments. Combined with readiness probes, this ensures no traffic hits a pod until it's fully ready, and no pod is removed until its replacement is serving. - Never store secrets in ConfigMaps or manifest files. Use Sealed Secrets for GitOps workflows or Vault injection for dynamic secrets rotation. PSP API keys and database credentials in plain YAML are a breach waiting to happen.
- Configure HPA scale-down stabilization to at least 5 minutes. Trading traffic is bursty. Without a cooldown window, the autoscaler will thrash between scaling up and down, causing instability during volatile market conditions.
- Deploy queue workers as separate Deployments with explicit queue priority. Financial transaction queues (deposits, withdrawals) must be processed before general background tasks. Separate deployments let you scale workers independently from web pods.
- Apply network policies to enforce least-privilege communication. If a pod only needs PostgreSQL and Redis, lock it down. Network policies are your last line of defense against lateral movement in a compromised cluster.
