DEV Community

Cover image for I handed Claude a docker-compose.yml and it deployed my app instantly
Rk
Rk

Posted on

I handed Claude a docker-compose.yml and it deployed my app instantly

It turns out you can hand a docker-compose.yml to Claude, point it at a container platform's MCP server, and get a working managed deploy back.

This is really cool, because moving a local compose stack onto a managed platform normally means one of three things: rewriting the deploy shape in whatever config language your target platform speaks, running a converter tool that only handles the parts compose and the platform both understand, or staying on a VM and running docker compose up directly.

Each of those requires an intervention, because compose describes a container topology while modern managed platforms need more than that to run your app well, including managed builds from a git source, auto-provisioned public URLs, cross-service secret references, and per-container scaling and resource specs.

The MCP-and-agent path skips all three: expose a platform's configuration API through an MCP server, and let an AI coding agent do the translation on demand from your compose file, without your compose file ever having to become anything else.

MCP (the Model Context Protocol) is an open standard for giving LLMs structured, permissioned access to external tools. If a platform ships MCP tools like create_environment, add_container, add_volume, and set_secret, an agent can read a compose file, translate the topology, generate the secrets it assumes are external, feed auto-allocated URLs back into env vars, flag missing durability settings, and stop short of actually deploying so a human still presses ship.

To see how it plays out, I pointed Claude (via Claude Code) at Suga's MCP server and asked it to stand up a real Laravel app's compose.yaml in a fresh environment.

The compose file

The example is taken from a Laravel app I run live as a side project to my main gig which is a hosting platform named Suga (https://suga.app), and since I work on Suga and I wanted to see if this would just work.

Here's the compose.yaml so you have the source material inline as we step through the translation:

x-app-env: &app-env
  APP_NAME: myapp
  APP_ENV: production
  APP_KEY: ${APP_KEY:?APP_KEY must be set (e.g. in .env)}
  APP_DEBUG: "false"
  APP_URL: http://localhost:8080
  LOG_CHANNEL: stderr
  LOG_LEVEL: info
  DB_CONNECTION: pgsql
  DB_HOST: postgres
  DB_PORT: "5432"
  DB_DATABASE: myapp
  DB_USERNAME: myapp
  DB_PASSWORD: myapp
  REDIS_HOST: redis
  REDIS_PORT: "6379"
  SESSION_DRIVER: redis
  CACHE_STORE: redis
  QUEUE_CONNECTION: redis

x-depends: &app-depends
  postgres:
    condition: service_healthy
  redis:
    condition: service_healthy

services:
  app:
    image: myapp:latest
    environment: *app-env
    depends_on: *app-depends
    ports:
      - "8080:8080"

  worker:
    image: myapp:latest
    command: ["php", "artisan", "queue:work", "--tries=3"]
    environment: *app-env
    depends_on: *app-depends

  scheduler:
    image: myapp:latest
    command: ["php", "artisan", "schedule:work"]
    environment: *app-env
    depends_on: *app-depends

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: myapp
    volumes:
      - postgres-data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  postgres-data:
Enter fullscreen mode Exit fullscreen mode

The three PHP services share an image built from a repo-root Dockerfile and pull in a large block of shared env vars via the x-app-env YAML anchor, while postgres gets a named volume for its data directory so writes survive a container restart. That's a fairly standard Laravel-plus-Redis-plus-Postgres shape, and anyone who has run compose in dev will recognize the topology immediately.

Suga's model, briefly

To follow along you need a quick sketch of how Suga models an environment, since it doesn't map one to one to compose services:

  • An environment contains a draft, which is a canvas of containers and volumes.
  • Each container has either an image reference or a build source (a connected GitHub repo), plus env vars, private networking, and optional public HTTPS.
  • Containers can reference each other's values e.g. {{<id>.variables.KEY}} where <id> is a short nanoid that Suga assigns when the container is created.
  • The MCP server exposes tools for shaping the draft, including create_environment, add_container, add_volume, set_env_variable, set_secret, connect_build_repository, and update_build_repository.

The MCP has no deploy tool by design, which is the safety rail: the agent crafts the draft and a human approves and deploys.

Wiring Claude Code up to Suga

I registered Suga's MCP server with Claude Code:

claude mcp add --transport http suga https://dashboard.suga.app/api/mcp
Enter fullscreen mode Exit fullscreen mode

Then I ran /mcp inside Claude Code to authenticate against my Suga account, and every Suga tool referenced in the rest of this post was callable directly from a Claude Code conversation. If you're using Cursor, Cline, or another MCP-capable coding agent instead of Claude Code, the Suga MCP connection guide has the equivalent setup instructions.

The prompt

The prompt I gave Claude was essentially

Deploy the compose.yaml at the repo root to Suga as a new environment on my existing project

with no accompanying instructions or hints, and that direction was enough for it to come back with a plan and a stream of MCP calls.

It did have access to the Laravel project

Translation, decision by decision

services: becomes five add_container calls

Each compose service becomes one add_container call, where the service name doubles as both the container's displayName and its networking.private.hostname. Keeping the hostname aligned with the compose service name matters because the app's env vars refer to services by name (DB_HOST=postgres, REDIS_HOST=redis), and cross-container DNS on Suga's private network resolves off the container's hostname, so the app reaches postgres and redis by the same names it uses locally.

image: myapp:latest becomes a GitHub build source

The myapp:latest tag in compose is really shorthand for "the image you get when you run docker build in this repo," so the natural translation on Suga is to give each PHP service a build source that actually produces that image. The agent did this by connecting app, worker, and scheduler to my GitHub repo on the main branch via connect_build_repository, pointing all three at the same repo-root Dockerfile.

There's a design choice worth calling out here: each Suga container has its own build source, which means you can point three containers at the same repo and Dockerfile for a monorepo setup, or at completely different repos and Dockerfiles for a multi-repo setup, depending on how your services are laid out. Compose's implicit "one image reused across services" pattern via the shared image field is the odd one out in that comparison, and if you want that same shared-image behaviour on Suga the pattern is to build once, push to a registry, and reference the tag from each container.

The agent picked the same-repo path here because it was the most direct translation of what the compose file was already expressing (three services sharing one Dockerfile, one build, one image), and because it stayed inside Suga (three connect_build_repository calls) rather than pulling in a separate registry push as a side quest.

ports: "8080:8080" becomes public HTTPS

The app service's port publish became networking.public.https[{port: 8080}] in the draft, at which point Suga auto-allocated a hostname on the cluster and returned a publicUrl immediately, before the deploy even happened. The response payload looked something like this: https://--..suga.run

Because that URL was available in the same MCP call that created the container, the agent could feed it back into the app's APP_URL env var in a follow-up call, wiring it up in the same pass rather than needing a second step to fill in the value later.

${APP_KEY:?...} becomes a generated secret

Compose's ${APP_KEY:?APP_KEY must be set (e.g. in .env)} syntax means "this variable must come from somewhere, fail loudly if unset." The agent recognized it as a Laravel APP_KEY, generated one with openssl rand -base64 32, and stored it as a sensitive variable in the draft so the value is write-only from that point on.

DB_PASSWORD: myapp becomes a sensitive cross-container reference

The compose file hardcodes the DB password on both the postgres service and the app services, which is convenient for local dev but worth improving on for a real deploy. The agent had a choice between hardcoding the literal password on both sides in Suga too or referencing postgres's secret from the app, and it picked the reference form: DB_PASSWORD = {{.variables.POSTGRES_PASSWORD}}

The <postgres-id> placeholder in that expression is where the postgres container's resource id goes, a nanoid that Suga assigned when the container was created (something in the shape of k3n8vpqm0xrs), and this reference form has no compose analogue at all, which is what makes it interesting: rotating the postgres password now updates every consuming container automatically on the next deploy, without the agent having to remember to keep two literal values in sync.

depends_on: {condition: service_healthy} gets dropped

Modern services are generally expected to retry their dependencies at startup rather than crash on the first failed connection, so the agent handled the compose depends_on gate by trusting that behaviour and letting the app come up naturally. That keeps the draft clean of compose-specific boot orchestration, and the retry logic built into most modern frameworks (Laravel included) does the rest at runtime.

Volumes

postgres-data:/var/lib/postgresql/data became a 1GB Suga volume mounted at the same path on the postgres container, which is essentially the same shape as the compose declaration with a size added.

What the agent noticed, that wasn't in the compose

The compose file doesn't say anything about Redis durability, which is a subtle gap worth flagging: Redis in production usually runs with --appendonly yes and a mounted volume so that a container restart doesn't erase the cache, session store, and queue state Laravel is putting there. The agent didn't quietly add either to the draft, staying faithful to the compose file as instructed, but it did flag the gap in its summary and noted that a real deploy of this shape would probably want durability configured before it ships. That's the kind of observation you get from an agent that has both the compose file in hand and general knowledge of how services like Redis are typically run in production, since a rules-based converter can only see what compose has spelled out.

The result

A couple of minutes and a few MCP calls later, the draft was populated and ready to review:

  • create_environment call created the new environment
  • add_container calls added app, worker, scheduler, postgres, and redis
  • add_volume call added postgres-data mounted on postgres
  • set_env_variable call fed the auto-allocated public URL back into APP_URL on the app container
  • connect_build_repository calls wired app, worker, and scheduler to the GitHub repo on the main branch

I opened the Suga link, logged in, reviewed the canvas, and clicked Deploy.

About ninety seconds later the deploy came back failed, with a clean and specific error surfaced right in the deployment record:

Build failed for service "worker": failed to solve: failed to read
dockerfile: open Dockerfile: no such file or directory
Enter fullscreen mode Exit fullscreen mode

That same message showed up once for each of the three source-built services, and it was the branch guess catching up with me. My main branch on this repo happens to be nearly empty, since all of the real code (including the Dockerfile) lives on develop, but the agent had no way to know that and had picked main as a reasonable default.

I told the agent the correct branch name in one sentence, and it made three update_build_repository calls (one per source-built container) to patch each build config in place, leaving the rest of the environment untouched.

I clicked Deploy on the same review link, and the second attempt went through end to end.

The blog-demo environment in the Suga dashboard after the successful deploy: five containers on the canvas with green health indicators and their private-network links, and the app container's config panel on the right showing the connected build source and the generated public URL.

The app came up and started responding to requests on the same public URL Suga had allocated back during add_container, which meant the URL I'd already fed into APP_URL on the app container was still the correct one, exactly as the pre-allocation flow implied it would be.

One thing worth noting from the logs: Suga starts all five containers in parallel, which is the fastest way to get a deploy up. During the first thirty seconds the scheduler's Laravel bootstrap tried to talk to postgres:5432 twice while postgres was still finishing initdb, both attempts were logged as expected connection errors, and the scheduler was healthy on subsequent connection attempts once postgres was ready (whether by Laravel retrying internally or by the container restarting and coming back up cleanly). Either path is a normal way for a modern app to handle a downstream service that takes a moment to warm up. For an app that wouldn't recover on its own (a one-shot init container or a strict migration job, for example), adding a container-level retry loop is a small tune-up worth doing in the second pass, once the first deploy is live and you can see everything talking.

What this is really about

For app devs, the reason this pattern matters is iteration speed. docker compose up gets your stack running locally in seconds, keeps every dependency described in one file, and lets you rebuild your mental model of the system quickly whenever you need to, which is why compose is still the default local dev setup for most multi-service apps in 2026. The friction hits the moment you want that same stack running on a managed platform rather than a raw VM, because your compose.yaml only describes half of what the platform needs to know, and you either translate the missing half by hand into whatever config language your platform expects or spend real build cycles pushing to CI just to iterate on the deploy shape.

An MCP-driven flow removes almost all of that friction. Your compose file stays the source of truth for how the app runs locally, and every time you change it, the same one-sentence prompt gets you a fresh production draft with the secrets generated, the build source wired up, the public URL allocated, and the cross-service references in place. The loop between "changed the compose file locally" and "reviewed a production draft" collapses to about the time it takes to type the prompt, which starts to feel a lot closer to the speed of local dev than the speed of a normal deploy pipeline.

Try it

If you want to point Claude at your own compose.yaml, the setup is short and free to try end to end. You'll need a Suga account first, which you can create at suga.app on the free tier, and once you're signed in you can follow the MCP connection guide to wire the Suga MCP server into Claude Code (or whichever MCP-capable agent you prefer). With the server connected, handing your compose file to the agent is a one-sentence prompt away, and you'll step through the same translation flow this post walked through, right up until you're the human clicking Deploy.

Top comments (0)