Immutable Infrastructure Principles

Reverend Philip Jan 7, 2026 9 min read

Replace servers instead of updating them. Learn immutable deployment patterns, AMI baking, and container-based infrastructure.

Immutable infrastructure replaces servers rather than updating them. Instead of patching a running system, you build a new image with the changes and deploy it. This approach eliminates configuration drift and makes deployments predictable.

Mutable vs Immutable

Mutable Infrastructure

With mutable infrastructure, servers accumulate changes over time. Each modification leaves the system in a slightly different state from its initial configuration.

Deploy app version 1.0
  ↓
SSH into server, update code
  ↓
Install new dependency
  ↓
Modify config file
  ↓
Server now in unknown state (what version? what configs?)

Problems:

  • Configuration drift between servers
  • "Works on my machine" / "Works on server A"
  • Difficult to reproduce issues
  • Rollbacks are risky

Immutable Infrastructure

Immutable infrastructure treats servers as disposable. Every change creates a new image, and you replace servers entirely rather than modifying them.

Build image with app v1.0 → Deploy image → Running server
Build image with app v1.1 → Deploy new image → Old server terminated

Server configuration is known because it came from the image.

Benefits:

  • Reproducible deployments
  • Easy rollbacks (deploy previous image)
  • No configuration drift
  • Simplified debugging

The key insight is that images are artifacts you can version, test, and store, just like your application code.

Building Images

Packer for AMIs

Packer automates the creation of machine images for multiple platforms. This example creates an AWS AMI with your application pre-installed.

# packer/app-server.pkr.hcl
packer {
  required_plugins {
    amazon = {
      version = ">= 1.0.0"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

source "amazon-ebs" "app" {
  ami_name      = "app-server-${var.version}-${formatdate("YYYYMMDD-hhmm", timestamp())}"
  instance_type = "t3.medium"
  region        = "us-west-2"

  source_ami_filter {
    filters = {
      name                = "ubuntu/images/hvm-ssd/ubuntu-22.04-amd64-server-*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    owners      = ["099720109477"] # Canonical
    most_recent = true
  }

  ssh_username = "ubuntu"
}

build {
  sources = ["source.amazon-ebs.app"]

  # Install system dependencies
  provisioner "shell" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y nginx php8.2-fpm php8.2-mysql",
      "sudo systemctl enable nginx php8.2-fpm",
    ]
  }

  # Copy application code
  provisioner "file" {
    source      = "../dist/"
    destination = "/tmp/app"
  }

  provisioner "shell" {
    inline = [
      "sudo mv /tmp/app /var/www/app",
      "sudo chown -R www-data:www-data /var/www/app",
    ]
  }

  # Copy configuration
  provisioner "file" {
    source      = "configs/nginx.conf"
    destination = "/tmp/nginx.conf"
  }

  provisioner "shell" {
    inline = [
      "sudo mv /tmp/nginx.conf /etc/nginx/sites-available/app",
      "sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/",
    ]
  }
}

The resulting AMI contains everything needed to run your application. Launching a new instance from this AMI gives you a fully configured server immediately.

Docker Images

Docker provides immutability at the container level. This Dockerfile creates a self-contained image with your PHP application.

# Dockerfile
FROM php:8.2-fpm-alpine

# Install dependencies
RUN apk add --no-cache \
    nginx \
    supervisor \
    && docker-php-ext-install pdo_mysql opcache

# Copy application
COPY --chown=www-data:www-data . /var/www/app

# Copy configuration
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/php.ini /usr/local/etc/php/php.ini
COPY docker/supervisord.conf /etc/supervisor/conf.d/

# Build assets
RUN cd /var/www/app && \
    composer install --no-dev --optimize-autoloader && \
    php artisan config:cache && \
    php artisan route:cache && \
    php artisan view:cache

EXPOSE 80

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

The multi-stage build pattern (not shown in full here) can further reduce image size by separating build-time dependencies from runtime dependencies.

CI/CD Pipeline for Image Building

Automate image building in your CI/CD pipeline. This GitHub Actions workflow builds a Docker image on every push to main and deploys it to ECS.

# .github/workflows/build-image.yml
name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.ECR_REGISTRY }}/app:${{ github.sha }}
            ${{ env.ECR_REGISTRY }}/app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest

    steps:
      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster production \
            --service app \
            --force-new-deployment

The git commit SHA as the image tag creates a direct link between code changes and deployed artifacts, making debugging easier.

Deployment Patterns

Blue-Green with Immutable Infrastructure

Blue-green deployments work naturally with immutable infrastructure. You're always deploying new instances rather than updating existing ones.

# terraform/main.tf
resource "aws_launch_template" "app" {
  name_prefix   = "app-"
  image_id      = var.ami_id  # New AMI for each deployment
  instance_type = "t3.medium"

  user_data = base64encode(<<-EOF
    #!/bin/bash
    # Minimal bootstrap - app already baked into image
    aws s3 cp s3://configs/app.env /var/www/app/.env
    systemctl start app
  EOF
  )
}

resource "aws_autoscaling_group" "app" {
  name                = "app-${var.version}"
  desired_capacity    = 3
  max_size           = 6
  min_size           = 3
  target_group_arns  = [aws_lb_target_group.app.arn]

  launch_template {
    id      = aws_launch_template.app.id
    version = "$Latest"
  }

  instance_refresh {
    strategy = "Rolling"
    preferences {
      min_healthy_percentage = 75
    }
  }
}

The instance refresh configuration ensures that deployments happen gradually, maintaining capacity throughout the update.

Kubernetes Deployments

Kubernetes is built around immutable infrastructure principles. Deployments automatically manage the transition from old to new container images.

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: app
          image: myregistry/app:v1.2.3  # Immutable tag
          ports:
            - containerPort: 80
          env:
            - name: APP_VERSION
              value: "v1.2.3"
          readinessProbe:
            httpGet:
              path: /health
              port: 80
            initialDelaySeconds: 5
            periodSeconds: 5

The readiness probe ensures traffic only flows to containers that are fully initialized and healthy.

Configuration Management

External Configuration

Secrets and environment-specific configuration shouldn't be baked into images. Inject them at runtime through environment variables.

// Don't bake secrets into images
// Inject at runtime via environment variables

// config/database.php
return [
    'default' => env('DB_CONNECTION', 'mysql'),
    'connections' => [
        'mysql' => [
            'host' => env('DB_HOST'),
            'database' => env('DB_DATABASE'),
            'username' => env('DB_USERNAME'),
            'password' => env('DB_PASSWORD'),
        ],
    ],
];

This separation means the same image can run in development, staging, and production with different configurations.

AWS Parameter Store / Secrets Manager

For AWS deployments, Parameter Store and Secrets Manager provide secure, centralized secret storage that integrates well with immutable infrastructure.

// app/Providers/ConfigServiceProvider.php
class ConfigServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        if (app()->environment('production')) {
            $this->loadSecretsFromAws();
        }
    }

    private function loadSecretsFromAws(): void
    {
        $ssm = new SsmClient(['region' => 'us-west-2']);

        $result = $ssm->getParameters([
            'Names' => [
                '/app/production/db_password',
                '/app/production/api_key',
            ],
            'WithDecryption' => true,
        ]);

        foreach ($result['Parameters'] as $param) {
            $key = basename($param['Name']);
            putenv("{$key}={$param['Value']}");
        }
    }
}

Secrets can be rotated without rebuilding images, and access can be controlled through IAM policies.

Config Maps in Kubernetes

Kubernetes separates configuration from images using ConfigMaps for non-sensitive data and Secrets for sensitive data.

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  APP_ENV: production
  LOG_LEVEL: info
  CACHE_DRIVER: redis
---
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  DB_PASSWORD: supersecret
  API_KEY: sk_live_xxx
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: app-secrets

Changes to ConfigMaps can trigger rolling updates, ensuring configuration changes are applied through the same immutable deployment process.

Handling Persistent Data

Separate Data from Compute

The key to immutable infrastructure is separating stateless compute from stateful data. Compute resources are disposable; data is not.

Immutable:
- Application code
- System configuration
- Dependencies

Mutable (external):
- Database (RDS, managed DB)
- File storage (S3, EFS)
- Cache (ElastiCache)
- Session storage (Redis)

Managed services handle the complexity of persistent data, letting you focus on immutable application deployment.

Ephemeral Local Storage

For temporary files and caches, use ephemeral storage that doesn't survive container restarts. This reinforces immutability.

# Kubernetes: Use emptyDir for temp files
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          volumeMounts:
            - name: temp
              mountPath: /tmp
            - name: cache
              mountPath: /var/cache/app
      volumes:
        - name: temp
          emptyDir: {}
        - name: cache
          emptyDir:
            medium: Memory  # RAM-based for speed

Memory-backed emptyDir volumes provide fast temporary storage without disk I/O overhead.

Rollbacks

Instant Rollbacks

With immutable infrastructure, rollbacks are just deployments of previous images. There's no need to reverse changes or restore backups.

# Docker/Kubernetes: Deploy previous image tag
kubectl set image deployment/app app=myregistry/app:v1.2.2

# AWS: Switch back to previous AMI
aws autoscaling update-auto-scaling-group \
  --auto-scaling-group-name app \
  --launch-template LaunchTemplateId=lt-xxx,Version=5  # Previous version

Because images are immutable and versioned, you know exactly what you're rolling back to.

Keeping Old Images

Maintain a retention policy for old images to enable rollbacks while controlling storage costs.

# ECR lifecycle policy: Keep last 10 images
{
  "rules": [
    {
      "rulePriority": 1,
      "description": "Keep last 10 images",
      "selection": {
        "tagStatus": "tagged",
        "tagPrefixList": ["v"],
        "countType": "imageCountMoreThan",
        "countNumber": 10
      },
      "action": {
        "type": "expire"
      }
    }
  ]
}

This policy automatically cleans up old images while keeping enough history for rollbacks.

Testing Images

Image Validation

Test images before they reach production. This script validates that an image starts correctly and passes basic health checks.

# Test image before deployment
#!/bin/bash

IMAGE=$1

# Start container
CONTAINER_ID=$(docker run -d -p 8080:80 $IMAGE)

# Wait for startup
sleep 10

# Health check
curl -f http://localhost:8080/health || exit 1

# Run smoke tests
./scripts/smoke-tests.sh http://localhost:8080 || exit 1

# Cleanup
docker stop $CONTAINER_ID

echo "Image $IMAGE passed validation"

Run this validation in CI before pushing to production registries.

Infrastructure Tests

InSpec and similar tools let you verify that images meet your requirements before deployment.

# InSpec tests for AMI
describe package('nginx') do
  it { should be_installed }
end

describe service('nginx') do
  it { should be_enabled }
  it { should be_running }
end

describe file('/var/www/app/public/index.php') do
  it { should exist }
  its('owner') { should eq 'www-data' }
end

describe port(80) do
  it { should be_listening }
end

These tests catch configuration errors before images reach production, reducing deployment failures.

Best Practices

Image Tagging

Use immutable, meaningful tags that create an audit trail. Avoid tags that can be overwritten.

# Use immutable tags
myapp:v1.2.3              # Semantic version
myapp:abc123def           # Git commit SHA
myapp:2024.01.15-1423     # Timestamp

# Avoid mutable tags in production
myapp:latest              # Don't use - changes over time
myapp:production          # Don't use - ambiguous

The combination of semantic version and commit SHA gives you both human-readable versions and precise traceability.

Small Images

Smaller images deploy faster and have less attack surface. Multi-stage builds help separate build tools from runtime dependencies.

# Multi-stage build for smaller images
FROM composer:2 AS builder
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts

FROM php:8.2-fpm-alpine
COPY --from=builder /app/vendor /var/www/app/vendor
COPY . /var/www/app

# Result: Only runtime dependencies, no build tools

Alpine-based images are significantly smaller than Debian or Ubuntu-based alternatives.

Security Scanning

Scan images for known vulnerabilities before deployment. Fail the pipeline if critical issues are found.

# Scan images for vulnerabilities
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ${{ env.IMAGE }}
    format: 'table'
    exit-code: '1'
    severity: 'CRITICAL,HIGH'

Regular scanning catches vulnerabilities in base images and dependencies before they reach production.

Conclusion

Immutable infrastructure eliminates configuration drift by treating servers as disposable. Build new images for every change, deploy them, and terminate old instances. Store configuration externally, keep data in managed services, and maintain previous images for instant rollbacks. The result is predictable, reproducible deployments that are easier to debug and scale.

Share this article

Related Articles

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.