Laravel on ECS Fargate: the whole pipeline, start to finish
Cloud & DevOps · Aug 2026 · 19 min read
Dockerfile, task definition, secrets, a migration gate, GitHub Actions, health checks that mean something, and a rollback that takes forty seconds.
This is the pipeline I put on every ECS project. It is about ninety lines of YAML and it has not changed materially in three years, which is the entire point — a deploy pipeline is infrastructure you should be able to stop thinking about. Here it is in full, with the reasoning attached to each part.
Assumptions: a Laravel app, an ECS cluster on Fargate, an ALB in front, RDS behind, and GitHub Actions. Swap the CI system freely; nothing here depends on it beyond the syntax.
Four stages, one gate, two rollback paths. About ninety lines of YAML in total.
Step 1: a Dockerfile that is not a liability
Two rules. Build dependencies never reach the runtime image, and the image is immutable once built. A container that compiles anything at start-up is a container that can fail at 3am for reasons unrelated to your code.
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction
FROM node:22 AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM php:8.3-fpm-alpine AS runtime # no composer, no node
RUN docker-php-ext-install pdo_mysql opcache bcmath
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
RUN php artisan config:cache && php artisan route:cache
USER www-data # not root. ever.
One caution on config:cache. It bakes environment values into the image at build time, so anything that differs between environments must come from the task definition rather than from a .env file. If you cache config and also ship a .env, you will spend an afternoon working out why production is reading staging's queue name.
Tip: Build the runtime stage explicitly with --target runtime. Without it, Docker builds the last stage in the file, and one reordered stage six months from now quietly ships an image with Composer and Node in it.
Step 2: the task definition, and where secrets go
There are two ways to give a container a database password. One of them puts it in an environment variable in a JSON file in your repository. Use the other one: ECS resolves secrets from Secrets Manager at task start, so the value never sits in the task definition, the image, or your CI logs.
"cpu": "512", "memory": "1024", "networkMode": "awsvpc",
"containerDefinitions": [{
"name": "app",
"image": "IMAGE_PLACEHOLDER", # CI substitutes the SHA tag
"environment": [
{ "name": "APP_ENV", "value": "production" }
],
"secrets": [ # resolved at task start
{ "name": "DB_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:...:app/db-password" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": { "awslogs-group": "/ecs/app" }
}
}]
Give the task two IAM roles and keep them distinct. The execution role pulls the image and reads the secrets; the task role is what your application code gets when it talks to S3 or SQS. Collapsing them into one is the shortcut that turns a container compromise into an account compromise.
Step 3: migrations as a gate, not a hope
Run migrations as a one-off ECS task that must exit zero before the service update begins. Not in the container entrypoint — that races every replica against every other replica. Not in a runbook — that is a step somebody will skip at 6pm on a Friday.
TASK=$(aws ecs run-task --cluster prod --launch-type FARGATE \
--task-definition app:$REVISION \
--overrides '{"containerOverrides":[{"name":"app",
"command":["php","artisan","migrate","--force"]}]}' \
--query 'tasks[0].taskArn' --output text)
aws ecs wait tasks-stopped --cluster prod --tasks $TASK
CODE=$(aws ecs describe-tasks --cluster prod --tasks $TASK \
--query 'tasks[0].containers[0].exitCode' --output text)
[ "$CODE" = "0" ] || { echo 'migration failed'; exit 1; }
# the deploy stops here. old tasks keep serving. nothing is half-done.
This forces a discipline on your migrations that is worth having anyway: every migration must be safe against the currently running code, because for the duration of the rolling update both versions are live. Add a column, deploy, backfill, deploy, then drop the old one. Never rename in a single step.
expand and contract, in four deploys Deploy Migration Code 1 — expand add email_normalised, nullable writes both columns 2 — backfill batched update, no schema change unchanged 3 — switch none reads the new column only 4 — contract drop email unchanged
Step 4: the deploy workflow
Tag the image with the commit SHA and never use latest, in any environment. This is the highest-leverage line in the file: it makes a rollback a redeploy of an artifact that has already been built and already been running, rather than a rebuild from a git tag under pressure.
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::...:role/gha-deploy # OIDC, no keys
aws-region: eu-west-1
- run: |
IMAGE=$REGISTRY/app:$GITHUB_SHA
docker build --target runtime -t $IMAGE .
docker push $IMAGE
- run: ./deploy/migrate.sh # step 3. must exit 0.
- run: |
aws ecs update-service --cluster prod --service app \
--task-definition app:$REVISION
aws ecs wait services-stable --cluster prod --services app
# wait, so a red build means a failed deploy, not a started one
Use OIDC rather than long-lived access keys stored as repository secrets. It takes twenty minutes to set up once and removes an entire category of credential from your organisation.
Step 5: health checks that mean something
The default health check everyone writes returns a static string, which proves the PHP process is alive and nothing else. A container that is listening but cannot reach its database should fail the check and be replaced, not quietly serve five hundreds while the dashboard stays green.
Route::get('/healthz', function () {
try {
DB::select('select 1'); // db reachable
Redis::connection()->ping(); // cache reachable
} catch (Throwable $e) {
Log::error('health check failed', ['e' => $e->getMessage()]);
return response('unhealthy', 503);
}
return response('ok', 200);
})->middleware('throttle:60,1'); // it is a public endpoint
# and give the app time to boot before judging it:
# healthCheckGracePeriodSeconds: 60
# deregistrationDelay: 30 (finish in-flight requests)
Do not put anything slow or expensive behind this route. The ALB will call it every fifteen seconds per target, forever. A select 1 and a ping are the right weight; a query that counts rows in your orders table is not.
Step 6: rollback in forty seconds
Two mechanisms, and you want both. The circuit breaker handles the deploy that never becomes healthy, automatically, while nobody is watching. The manual path handles the deploy that is perfectly healthy and also wrong, which the breaker cannot detect because nothing is failing.
# automatic: in the service definition, set once and forget
"deploymentConfiguration": {
"deploymentCircuitBreaker": { "enable": true, "rollback": true },
"minimumHealthyPercent": 100, "maximumPercent": 200
}
# manual: the previous SHA was already built and already ran
aws ecs update-service --cluster prod --service app \
--task-definition app:$PREVIOUS_REVISION
# no rebuild, no CI queue, no git archaeology at 6pm.
Rehearse the manual one. A rollback procedure that has never been run is a document, not a capability, and the first time you exercise it should not be during an incident.
AWS re:Invent 2024 — Deployment best practices for reliable rollouts using Amazon ECS (SVS340)
AWS re:Invent 2024 (SVS340) — worth an hour if you are weighing rolling updates against blue/green and canary.
What is deliberately missing
No bespoke deploy script living on one engineer's laptop. No manual migration step in a runbook. No staging environment hand-built in the console two years ago that now differs from production in ways nobody can enumerate — every environment comes from the same template with different parameters. No blue/green, until somebody can articulate what it buys over a rolling update with a circuit breaker on this particular service. No autoscaling on day one. Get the deploy boring first; scaling policy is a separate problem with separate evidence.
If a deploy needs a human in a terminal, it is not finished — it is a rehearsal.
The measure of this pipeline is not how clever it is. It is that new engineers deploy on their first day, that rollback is a command rather than a meeting, and that nobody on the team can remember the last time a deploy itself caused an incident. Boring is the deliverable.
References
Takeaways
Multi-stage build, immutable SHA tags, non-root runtime — never latest, in any environment. Gate the service update on a one-off migration task that must exit zero, and keep migrations backwards-compatible. Resolve secrets from Secrets Manager at task start, and keep the execution role separate from the task role. Health-check the dependencies, not the process, and rehearse the manual rollback before you need it.
All notes · Shehzad Aslam