Hosting a Laravel app on one AWS EC2 instance with Docker, Caddy and Cloudflare
The exact setup behind this portfolio: Docker Compose on a single EC2 instance, Caddy for HTTPS, Cloudflare in front, and a security group that only lets Cloudflare in.
This portfolio runs on one small AWS EC2 instance. No Kubernetes, no load balancer, no managed database. That is a deliberate choice, and for a portfolio or a small client application it is a good one: it is cheap, easy to reason about, and fully reproducible from a Git repository.
This article describes the setup end to end — what runs where, why, and which trade-offs I accepted.
The architecture in one picture
Visitor
└─► Cloudflare (proxy + TLS)
└─► Caddy :443 (Cloudflare Origin CA certificate)
└─► app :8080 (nginx + php-fpm, Laravel)
├─► MySQL container (named volume)
└─► queue worker container
Everything on the right of Cloudflare lives on a single EC2 instance, managed by Docker Compose.
Why a single instance
A load-balanced, multi-AZ setup solves problems a personal site does not have. One instance gives me:
- A small monthly bill — a
t3.microand a small disk. - One place to look when something breaks.
- A deployment that fits in my head —
git pull, thendocker compose up.
The honest downside: this is a single point of failure. If the instance dies, the site is down until I rebuild it. I accept that for a portfolio, and I reduce the pain by keeping the whole infrastructure in code (see bringing existing AWS infrastructure under Terraform).
Layering Compose files
Instead of one giant file, I use three:
| File | Purpose |
|---|---|
compose.yaml |
The base: app, queue and mysql services |
compose.override.yaml |
Local development only: bind-mounts the source code |
compose.prod.yaml |
Production only: adds Caddy, hides the app port, rotates logs |
On the server I run:
docker compose -f compose.yaml -f compose.prod.yaml up -d --build
Because I name the files explicitly, the local override is never loaded in production. The production overlay also unpublishes the app's port, so the only way in is through Caddy:
services:
app:
ports: !override []
!override replaces the list from the base file instead of merging with it, and needs Docker Compose v2.24.4 or newer.
HTTPS with Caddy and a Cloudflare Origin certificate
Cloudflare terminates TLS for visitors. For the hop between Cloudflare and my server I use a Cloudflare Origin CA certificate, which Cloudflare trusts and which is valid for years — so there is no renewal job to forget.
Caddy only needs to be pointed at the certificate files:
{
admin off
}
example.com {
tls /etc/caddy/certs/origin.pem /etc/caddy/certs/origin.key
encode zstd gzip
reverse_proxy app:8080
}
Because the tls directive names explicit files, Caddy does not try to obtain a Let's Encrypt certificate. In Cloudflare, set the SSL/TLS mode to Full (strict) so the connection to the origin is encrypted and verified.
The certificate and key are not in Git; I create them on the server once.
Letting only Cloudflare reach the server
This is the part most tutorials skip, and it matters. If your origin accepts traffic from anywhere, anyone who discovers the server's IP can bypass Cloudflare entirely.
The security group allows:
- Ports 80 and 443 only from Cloudflare's published IP ranges.
- Port 22 (SSH) only from my own IP address.
Everything else is closed. The app container has no published port at all, so even a misconfigured firewall would not expose it directly.
Behind a proxy chain, Laravel also needs to trust the forwarded headers to see the real visitor IP and the HTTPS scheme. Configure trusted proxies in bootstrap/app.php; otherwise rate limiting and logs record Cloudflare's address instead of the visitor's.
The database: container, not RDS
MySQL runs as a container with a named volume:
mysql:
image: mysql:8.4
restart: unless-stopped
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p$$MYSQL_ROOT_PASSWORD"]
interval: 5s
retries: 20
This keeps the cost down, but it moves responsibility to me: there are no automatic managed backups. If you do this, schedule your own database dumps or disk snapshots and test a restore. A backup you have never restored is only a hope.
Small details that save you later
- Log rotation. Docker's default JSON logs grow forever. Set
max-size: "10m"andmax-file: "3"on every service, or a busy week can fill the disk. - Healthchecks and
depends_on. Caddy waits for the app to be healthy, and the app waits for MySQL. Cold starts stop being a race. I cover that in running a Laravel queue worker in Docker Compose. - Named volumes for state. Uploads and the database survive
docker compose down.
When this setup is the wrong choice
Choose something else if you need zero-downtime deploys, horizontal scaling, or a strict recovery-time objective. Past that point, managed databases, a load balancer and an orchestrator earn their cost. Below it, one well-configured instance is hard to beat.
Checklist
- Only Cloudflare can reach ports 80 and 443.
- SSH is restricted to a single address.
- The app port is not published.
- TLS is Full (strict) end to end.
- Logs are rotated, state is in volumes.
- You have a backup and you have tested restoring it.
To automate shipping changes to a server like this, see a CI/CD pipeline for Laravel with GitHub Actions and a self-hosted runner.
More articles
Running a Laravel queue worker in Docker Compose without migration races
Put your queue worker in its own container, make it wait for a healthy database, stop it from re-running migrations, and restart it cleanly on every deploy.
· 4 min read
Technical SEO for a Laravel and Livewire portfolio: what I actually implemented
A practical checklist from building this site: per-page meta, canonical URLs, Open Graph, JSON-LD, a dynamic sitemap and Search Console — with the code and the things I deliberately skipped.
· 4 min read
Laravel CI/CD with GitHub Actions and a self-hosted runner on EC2
Run tests on GitHub, then deploy from a runner that lives on the server itself — no SSH keys in secrets, no inbound deploy port, and a deploy that never gets cancelled halfway.
· 4 min read