Skip to content

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.

Gohar Nawaz 4 min read
Docker, Laravel and AWS icons with the title Hosting a Laravel app on one AWS EC2 instance

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.micro and a small disk.
  • One place to look when something breaks.
  • A deployment that fits in my head — git pull, then docker 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" and max-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.