Skip to main content

Docker and Kubernetes for Fintech Deployment

00:08:45:29

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.

dockerfile
# 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 --from=composer-deps /app/vendor ./vendor
COPY --from=frontend-build /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 --interval=30s --timeout=5s --start-period=10s --retries=3 \
    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.

yaml
# 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

yaml
# 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.

yaml
# 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:

yaml
# 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.

yaml
# 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.

yaml
# 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.

yaml
# 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.

yaml
# 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

  1. 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.
  2. Set maxUnavailable: 0 on 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Want to discuss this topic or work together? Get in touch.

Contact me

Related Articles

agile-leadership-in-fintech-teams

api-design-for-fintech-platforms

building-scalable-fintech-crms-with-laravel