# Migrate a Docker container app from AWS to Quake AI

Source: https://docs.quake.ai/docs/compute/migration/migrate-docker-app-from-aws
Markdown: https://docs.quake.ai/docs/compute/migration/migrate-docker-app-from-aws.md

---

# Migrate a Docker container app from AWS to Quake AI

You use this guide to move a containerized application from AWS (ECS, Fargate, or EC2 with Docker) to Quake AI (OpenStack Nova). The migration shifts you from managed orchestration to self-managed containers on VMs. You control the runtime, networking, and scaling directly.

If you have not reviewed how AWS concepts map to Quake AI, read [Coming from AWS](/resources/migration/coming-from-aws) first. For VM-level migration (non-containerized workloads), see [Migrate from EC2 to Quake AI Compute](/docs/compute/migration/migrate-from-ec2).

## Prerequisites

- A Quake AI account with [application credentials](/docs/tools/generate-app-credentials)
- [OpenTofu](/docs/automation/how-to/getting-started-iac) (or Terraform) installed and configured with the `openstack` provider
- The [OpenStack CLI](/docs/tools/install-openstack-client) installed (for verification and ad-hoc commands)
- SSH access configured ([add your SSH key](/docs/tools/add-ssh-key))
- Docker installed on your local machine (for image pull/push operations)
- Access to your AWS account with permissions to pull images from ECR

## ECS and fargate concept translation

AWS wraps containers in managed abstractions. On Quake AI, you work directly with Docker (or Kubernetes) on Nova instances. The table below maps each AWS container concept to its Quake AI equivalent.

| AWS concept | Quake AI equivalent | Notes |
|---|---|---|
| ECS task definition | `docker-compose.yml` or systemd unit | Port mappings, environment variables, volumes, health checks, and resource limits translate directly to Compose service definitions |
| ECS service | Docker Compose service with `restart: unless-stopped` | For Kubernetes: a Deployment with replica count |
| Fargate (serverless containers) | Nova instance + Docker | No serverless container equivalent. You provision and manage the VM, with full SSH access and runtime control. |
| AWS Lambda (event handlers, background workers) | Docker container or systemd timer | No serverless function runtime. Replace Lambda with a container or a script triggered by a queue, scheduled cron, or systemd timer. |
| ECR (container registry) | Docker Hub, GitHub Container Registry (GHCR), or self-hosted registry | Push images to a portable registry before migrating. No vendor-specific authentication required on the destination. |
| ALB + target groups | Self-managed reverse proxy (Caddy, nginx, HAProxy, or Traefik), or an external CDN and WAF | Use a containerized reverse proxy on one VM, or route an external edge service to floating IPs on multiple VMs. |
| ECS service discovery | Docker Compose DNS (automatic between services) or Consul/CoreDNS for multi-host | Containers in the same Compose file resolve each other by service name. |
| CloudWatch Container Insights | Self-managed Prometheus + cAdvisor + Grafana | See [Monitoring](/docs/operate/monitoring) for guidance on observability patterns. |
| AWS Secrets Manager / Parameter Store | Environment variables, Docker secrets, or HashiCorp Vault | For Compose: use `.env` files (not committed to version control) or Docker secrets for swarm mode. |
| ECS Exec | `docker exec` over SSH | SSH into the Nova instance, then `docker exec -it CONTAINER_NAME /bin/sh`. |
| ECR lifecycle policies | Registry-specific cleanup rules or manual `docker image prune` | Docker Hub and GHCR have retention policies. Self-hosted registries need manual or scripted cleanup. |
| CloudFormation / CDK (container infra) | [OpenTofu](/docs/automation/how-to/getting-started-iac) with the `openstack` provider | See [Migrate from CloudFormation](/docs/automation/migration/from-aws-cloudformation) for the full IaC translation. |

## Choose your deployment target

Quake AI does not have a managed container service. You run containers on Nova instances using one of three approaches. Pick the one that matches your operational complexity and scaling needs.

| Approach | Best for | Complexity | Scaling model |
|---|---|---|---|
| **Single VM + Docker Compose** | Small-to-medium apps with fewer than ~10 containers. Most ECS migrations start here. | Low | Vertical: resize the VM flavor. Horizontal: add VMs behind an external edge or self-managed reverse-proxy tier. |
| **Multiple VMs + Docker Compose** | Workloads that need isolation between services (separate DB host, separate worker host) or independent scaling. | Medium | Horizontal: each VM scales independently. Use a [private network](/docs/network/how-to/create-network) for inter-VM traffic. |
| **Self-managed Kubernetes** (k3s, RKE2, kubeadm) | Teams already operating Kubernetes who want pod orchestration, rolling deploys, and auto-scaling. | High | Kubernetes-native: replica sets, HPA, cluster autoscaler. |

**If you are migrating from ECS or Fargate**, start with the single-VM Compose approach unless your workload already exceeds what a single `m2a.4xlarge` (16 vCPU, 64 GiB) can handle. You can promote to multi-VM or Kubernetes later without changing your container images.

**If you are migrating from EKS**, use the [EKS to Kubernetes on Quake AI](/docs/kubernetes/migration/migrate-from-eks) guide instead.

## Migration workflow

### 1. Export images from ECR

Pull every image your task definitions reference, retag them for a portable registry, and push. Replace the placeholders with your values.

```bash
# Authenticate to ECR
aws ecr get-login-password --region AWS_REGION | \
  docker login --username AWS --password-stdin \
  AWS_ACCOUNT_ID.dkr.ecr.AWS_REGION.amazonaws.com

# Pull, retag, push for each image
docker pull AWS_ACCOUNT_ID.dkr.ecr.AWS_REGION.amazonaws.com/APP_NAME:TAG
docker tag AWS_ACCOUNT_ID.dkr.ecr.AWS_REGION.amazonaws.com/APP_NAME:TAG \
  REGISTRY_HOST/REGISTRY_NAMESPACE/APP_NAME:TAG
docker push REGISTRY_HOST/REGISTRY_NAMESPACE/APP_NAME:TAG
```

Repeat for every image in your task definitions. If you use multi-architecture builds (`linux/amd64` + `linux/arm64`), push the manifest list. Quake AI compute runs AMD64.



If you have many images, script the loop:

```bash
for repo in APP_ONE APP_TWO APP_THREE; do
  docker pull AWS_ACCOUNT_ID.dkr.ecr.AWS_REGION.amazonaws.com/$repo:latest
  docker tag AWS_ACCOUNT_ID.dkr.ecr.AWS_REGION.amazonaws.com/$repo:latest \
    REGISTRY_HOST/REGISTRY_NAMESPACE/$repo:latest
  docker push REGISTRY_HOST/REGISTRY_NAMESPACE/$repo:latest
done
```



### 2. Translate the task definition to docker-compose.yml

An ECS task definition defines containers, ports, environment variables, volumes, health checks, and resource limits in JSON. The equivalent on Quake AI is a `docker-compose.yml` file.

**Example ECS task definition (abbreviated):**

```json
{
  "family": "web-app",
  "networkMode": "awsvpc",
  "containerDefinitions": [
    {
      "name": "api",
      "image": "AWS_ACCOUNT_ID.dkr.ecr.AWS_REGION.amazonaws.com/api:v2.1",
      "portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
      "environment": [
        { "name": "DATABASE_URL", "value": "postgres://db:5432/app" },
        { "name": "REDIS_URL", "value": "redis://cache:6379" }
      ],
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3
      },
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": { "awslogs-group": "/ecs/web-app" }
      }
    },
    {
      "name": "cache",
      "image": "redis:7-alpine",
      "portMappings": [{ "containerPort": 6379 }]
    }
  ]
}
```

**Equivalent `docker-compose.yml` for Quake AI:**

```yaml
services:
  api:
    image: REGISTRY_HOST/REGISTRY_NAMESPACE/api:v2.1
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: "postgres://db:5432/app"
      REDIS_URL: "redis://cache:6379"
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
    restart: unless-stopped
    depends_on:
      - cache

  cache:
    image: redis:7-alpine
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: app
      POSTGRES_PASSWORD: "${DB_PASSWORD}"
    restart: unless-stopped

volumes:
  db_data:
```

The Compose file adds a `db` service that was not in the ECS task definition. On AWS, the database likely runs on RDS. On Quake AI, you run it as a container alongside your application or on a separate VM. Adjust the service list to match your actual stack.

**Translation rules:**

| ECS task definition field | docker-compose.yml equivalent |
|---|---|
| `containerDefinitions[].image` | `services.SERVICE.image` (update the registry URL) |
| `portMappings[].containerPort` | `services.SERVICE.ports` (`HOST_PORT:CONTAINER_PORT`) |
| `environment[]` | `services.SERVICE.environment` (key-value pairs) |
| `secrets[]` (from Secrets Manager or SSM Parameter Store) | `services.SERVICE.env_file` pointing to a `.env` file on disk, or Docker secrets for sensitive values. `.env` files store plaintext; prefer Docker secrets or HashiCorp Vault for production workloads. |
| `healthCheck` | `services.SERVICE.healthcheck` (same structure, different syntax) |
| `logConfiguration` (`awslogs` or `awsfirelens`) | Docker logging driver (see [logging section](#7-set-up-logging)). For `awslogs`, use the JSON file driver. For `awsfirelens`, run Promtail, Fluentd, or Vector as a sidecar container. |
| `cpu` / `memory` | `services.SERVICE.deploy.resources.limits` (optional; omit for single-app VMs) |
| `volumes[].host.sourcePath` | `services.SERVICE.volumes` (bind mounts or named volumes) |
| `dependsOn` | `services.SERVICE.depends_on` |

### 3. Provision infrastructure and deploy with OpenTofu

OpenTofu is the direct replacement for CloudFormation and CDK. It provisions the VM, network, security groups, and block storage, then cloud-init installs Docker and starts your containers on first boot. Your `docker-compose.yml` travels unchanged inside the cloud-init payload.

For the full IaC setup walkthrough, see [Get started with Infrastructure as Code on Quake AI](/docs/automation/how-to/getting-started-iac). For CloudFormation-specific resource mapping, see [Migrate from AWS CloudFormation](/docs/automation/migration/from-aws-cloudformation).

Create a `main.tf` that provisions everything your containers need:

```hcl
terraform {
  required_providers {
    openstack = {
      source = "terraform-provider-openstack/openstack"
    }
  }
}

variable "app_name" {
  default = "web-app"
}

variable "image" {
  default = "Ubuntu-24.04"
}

variable "flavor" {
  default = "m2a.xlarge"
}

variable "network" {
  type = string
}

variable "key_name" {
  type = string
}

variable "db_password" {
  type      = string
  sensitive = true
}

resource "openstack_networking_secgroup_v2" "app" {
  name        = "${var.app_name}-sg"
  description = "Security group for ${var.app_name}"
}

resource "openstack_networking_secgroup_rule_v2" "ssh" {
  security_group_id = openstack_networking_secgroup_v2.app.id
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 22
  port_range_max    = 22
  remote_ip_prefix  = "0.0.0.0/0"
}

resource "openstack_networking_secgroup_rule_v2" "app_port" {
  security_group_id = openstack_networking_secgroup_v2.app.id
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 8080
  port_range_max    = 8080
  remote_ip_prefix  = "0.0.0.0/0"
}

resource "openstack_blockstorage_volume_v3" "data" {
  name = "${var.app_name}-data"
  size = 50
}

resource "openstack_networking_port_v2" "app" {
  network_id         = var.network
  security_group_ids = [openstack_networking_secgroup_v2.app.id]
}

resource "openstack_compute_instance_v2" "app" {
  name        = var.app_name
  image_name  = var.image
  flavor_name = var.flavor
  key_pair    = var.key_name

  user_data = templatefile("cloud-init.yaml", {
    db_password = var.db_password
  })

  network {
    port = openstack_networking_port_v2.app.id
  }
}

resource "openstack_compute_volume_attach_v2" "data" {
  instance_id = openstack_compute_instance_v2.app.id
  volume_id   = openstack_blockstorage_volume_v3.data.id
}

resource "openstack_networking_floatingip_v2" "app" {
  pool = "PublicStatic"
}

resource "openstack_networking_floatingip_associate_v2" "app" {
  floating_ip = openstack_networking_floatingip_v2.app.address
  port_id     = openstack_networking_port_v2.app.id
}

output "floating_ip" {
  value = openstack_networking_floatingip_v2.app.address
}
```

Adjust `port_range_min` / `port_range_max` to match the ports your containers expose. Add additional security group rules for HTTPS (443) or any other public ports. Attach groups to the Neutron port with `security_group_ids`, not to the instance by name. See [Authoring IaC templates](/docs/automation/concepts/authoring-iac-templates). See the [flavor mapping table](/docs/compute/migration/migrate-from-ec2#flavor-mapping) in the EC2 migration guide to match your current AWS instance size to a Quake AI flavor.

### 4. Deploy containers with cloud-init

Cloud-init runs on first boot to install Docker, mount the data volume, write your `docker-compose.yml`, and start the stack. Create a `cloud-init.yaml` alongside your `main.tf`:

```yaml
#cloud-config
package_update: true

write_files:
  - path: /opt/app/docker-compose.yml
    content: |
      services:
        api:
          image: REGISTRY_HOST/REGISTRY_NAMESPACE/api:v2.1
          ports:
            - "8080:8080"
          environment:
            DATABASE_URL: "postgres://db:5432/app"
            REDIS_URL: "redis://cache:6379"
          healthcheck:
            test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
            interval: 30s
            timeout: 5s
            retries: 3
          restart: unless-stopped
          depends_on:
            - cache
            - db

        cache:
          image: redis:7-alpine
          restart: unless-stopped

        db:
          image: postgres:16-alpine
          volumes:
            - /mnt/app-data/pgdata:/var/lib/postgresql/data
          environment:
            POSTGRES_DB: app
            POSTGRES_PASSWORD: "${db_password}"
          restart: unless-stopped

  - path: /etc/docker/daemon.json
    content: |
      {
        "log-driver": "json-file",
        "log-opts": { "max-size": "50m", "max-file": "3" }
      }

runcmd:
  - curl -fsSL https://get.docker.com | sh
  - mkfs.ext4 /dev/vdb || true
  - mkdir -p /mnt/app-data/pgdata
  - mount /dev/vdb /mnt/app-data
  - echo '/dev/vdb /mnt/app-data ext4 defaults 0 2' >> /etc/fstab
  - cd /opt/app && docker compose pull && docker compose up -d
```



The `docker compose pull` step assumes the image at `REGISTRY_HOST/REGISTRY_NAMESPACE/api:v2.1` is publicly pullable. If your registry is private, add a `docker login` step to the `runcmd` block before the pull (for example, `echo "$REGISTRY_TOKEN" | docker login REGISTRY_HOST --username REGISTRY_USER --password-stdin`), and inject the token through a Terraform variable via `templatefile()` rather than hardcoding it. Without authentication, cloud-init cannot pull the image and the stack does not start.



Replace the `docker-compose.yml` content with the Compose file you translated in step 2. The `${db_password}` variable is injected by OpenTofu's `templatefile()` function from your `terraform.tfvars`.

**Apply the configuration:**

```bash
tofu init
tofu plan
tofu apply
```

OpenTofu provisions the VM, attaches the block volume, and associates the floating IP. Cloud-init installs Docker, mounts the volume, and starts your containers. No manual SSH required for the initial deploy.

**Verify the deployment** by SSHing into the instance:

```bash
ssh ubuntu@$(tofu output -raw floating_ip)
docker compose -f /opt/app/docker-compose.yml ps
```

Every service should show `Up` with its health status. If a container fails to start, check its logs:

```bash
docker compose -f /opt/app/docker-compose.yml logs SERVICE_NAME
```



For iterative development before codifying in OpenTofu, you can provision manually with the OpenStack CLI and SSH in to test your Compose file. See [Create an instance](/docs/compute/how-to/create-instance) for the CLI workflow. Once the deployment is stable, capture it in OpenTofu for repeatability.



For a deeper walkthrough of the OpenTofu + cloud-init pattern, including multi-service topologies and volume management, see [Migrate from Docker Compose to OpenTofu on Quake AI](/docs/automation/migration/from-docker-compose).

### 5. Configure networking and traffic routing

**Single-VM deployment:** Your security group rules and floating IP handle public access. Traffic reaches the VM on the published port, and Docker's port mapping routes it to the container.

**Multi-VM deployment:** Attach floating IPs to the public application or proxy instances. Put an external CDN or WAF with health-checked origins in front of those addresses, or run a dedicated reverse-proxy tier that reaches application instances over a private network.

**Self-managed reverse proxy:** For HTTPS termination, domain routing, or path-based routing on a single VM, add a reverse proxy container (Caddy, nginx, or Traefik) to your Compose file:

```yaml
services:
  reverse-proxy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
    restart: unless-stopped

volumes:
  caddy_data:
```

This replaces the ALB. Caddy handles automatic HTTPS via Let's Encrypt; the only configuration is the `Caddyfile`.

### 6. Set up health checks and auto-restart

Docker Compose `restart: unless-stopped` ensures containers restart after crashes and after VM reboots. The `healthcheck` directive (translated from your ECS task definition in step 2) lets Docker track container health. If you deployed via cloud-init (step 4), the `runcmd` block already starts your stack on first boot, and the restart policy keeps it running across reboots.

If you deployed manually (without cloud-init) and need boot-time startup, create a systemd unit:

```bash
sudo tee /etc/systemd/system/app-compose.service > /dev/null <<'UNIT'
[Unit]
Description=App Docker Compose
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/app
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable app-compose.service
```

### 7. Set up logging

ECS sends container logs to CloudWatch by default. On Quake AI, configure Docker's logging driver.

**Option A: JSON file driver (default).** Logs are stored on the VM at `/var/lib/docker/containers/`. Use `docker compose logs` to read them. The cloud-init template in step 4 already configures log rotation in `/etc/docker/daemon.json`. If you deployed manually, add log rotation to prevent disk exhaustion:

```json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "50m",
    "max-file": "3"
  }
}
```

Save this as `/etc/docker/daemon.json` and restart Docker (`sudo systemctl restart docker`).

**Option B: Ship to a central stack.** Run Promtail, Fluentd, or Vector as a sidecar container to forward logs to Loki, Elasticsearch, or a similar backend. See [Monitoring](/docs/operate/monitoring) for observability patterns on Quake AI.

## Migrate application data

### File-based data

Transfer files from your ECS host or EFS mount to the Quake AI instance:

```bash
rsync -avz --progress -e "ssh -i SSH_KEY_PATH" \
  LOCAL_DATA_PATH ubuntu@FLOATING_IP:/opt/app/data/
```

### Databases

If your containers connect to RDS, migrate the database to a self-managed instance on Quake AI. Dump from the source, restore to the destination.

Before restoring, confirm the `db` container is running on the Quake AI instance (`docker compose ps`). For RDS instances without a public endpoint, run the dump from an EC2 instance in the same VPC and pipe to the Quake AI target.

```bash
# PostgreSQL
pg_dump -h RDS_ENDPOINT -U DB_USER DB_NAME | \
  ssh ubuntu@FLOATING_IP "docker exec -i db psql -U DB_USER DB_NAME"

# MySQL
mysqldump -h RDS_ENDPOINT -u DB_USER -p DB_NAME | \
  ssh ubuntu@FLOATING_IP "docker exec -i db mysql -u DB_USER -p DB_NAME"
```

### Persistent volumes

The OpenTofu configuration in step 3 already provisions and attaches a Cinder block volume, and the cloud-init script formats, mounts, and adds it to `/etc/fstab`. The Compose file bind-mounts the volume path into your container:

```yaml
services:
  db:
    volumes:
      - /mnt/app-data/pgdata:/var/lib/postgresql/data
```

To resize the volume later, update the `size` attribute in `openstack_blockstorage_volume_v3` and run `tofu apply`. See [Create and attach a block volume](/docs/block/how-to/create-volume) for the full guide.

## Validation checklist

After deploying on Quake AI, verify:

- [ ] All containers report healthy (`docker compose ps` shows `Up (healthy)`)
- [ ] Application responds on the expected ports through the floating IP
- [ ] Security group rules allow the same traffic patterns as your AWS configuration
- [ ] Data integrity: compare row counts, file checksums, or application-level validation
- [ ] DNS records updated to point to the Quake AI floating IP (use low TTLs during transition)
- [ ] Monitoring in place: container metrics, logs, and application-level health
- [ ] Auto-restart works: reboot the VM and confirm all containers come back up
- [ ] SSL/TLS certificates installed and renewing (if using HTTPS)
- [ ] Environment variables and secrets loaded correctly (no hardcoded AWS-specific values)

## What changes, what stays the same

| Aspect | Changes | Stays the same |
|---|---|---|
| **Container images** | Registry URL (ECR → portable registry) | Dockerfile, build process, image contents |
| **Orchestration** | ECS/Fargate → Docker Compose on a VM | Container networking, port mappings, health checks |
| **Infrastructure** | CloudFormation/CDK → OpenTofu with the `openstack` provider | Infrastructure as Code workflow (`tofu plan` / `tofu apply`) |
| **Networking** | ALB + target groups → security groups, floating IPs, and an external edge or self-managed reverse proxy | Container-to-container DNS within Compose |
| **Storage** | EBS/EFS → Cinder block volumes | Docker volume mounts inside the VM |
| **Logging** | CloudWatch → JSON file driver or self-managed log stack | Application log output (stdout/stderr) |
| **Secrets** | Secrets Manager → `.env` files, Docker secrets, or Vault | Environment variable consumption in containers |
| **Scaling** | ECS auto-scaling → manual VM resize or add VMs behind an external edge or reverse-proxy tier | Horizontal scaling via multiple container replicas |
| **Cost model** | Per-task/per-vCPU-second billing → per-VM hourly billing | Predictable: no burst-traffic surcharges |

## See also

- [Migrating from AWS to Quake AI](/resources/migration/from-aws): full cross-service migration hub
- [Migrate from EC2](/docs/compute/migration/migrate-from-ec2): VM-level migration with flavor mapping
- [Get started with Infrastructure as Code](/docs/automation/how-to/getting-started-iac): OpenTofu setup and `openstack` provider configuration
- [Migrate from AWS CloudFormation](/docs/automation/migration/from-aws-cloudformation): CloudFormation resource → OpenTofu resource mapping
- [Migrate from Docker Compose to OpenTofu](/docs/automation/migration/from-docker-compose): deeper dive on Compose + cloud-init patterns
- [Migrate from EKS](/docs/kubernetes/migration/migrate-from-eks): Kubernetes-level migration
- [Coming from AWS](/resources/migration/coming-from-aws): concept translation reference
- [Deploy a containerized web application](/docs/quickstart/deploy-containerized-app): swap, resource limits, and multi-container patterns on smaller VMs
- [Monitoring](/docs/operate/monitoring): observability patterns on Quake AI
