Deploying a Laravel Application to Amazon EC2 Using Terraform
Blog article/Blog archive

Deploying a Laravel Application to Amazon EC2 Using Terraform

Not everything needs to be serverless. I know, I know. I literally write about Lambda and API Gateway all the time. But look, sometimes you just need a single EC2 instance running your Laravel app and

Apr 11, 202614 min read0 comments72 views
TerraformAWSLaravelDevopswebdev

Not everything needs to be serverless. I know, I know. I literally write about Lambda and API Gateway all the time. But look, sometimes you just need a single EC2 instance running your Laravel app and that's totally fine. I was setting this up for a client last month and kept going back and forth on whether to containerize it or just throw it on EC2. EC2 won. Took me 20 minutes.

I still reach for EC2 more than you'd think. Small internal tools, staging environments, side projects where I really don't want to think about cold starts or container orchestration for a PHP app. It just works.

So in this guide, we're going to set up a production-ready EC2 environment for Laravel using Terraform. VPC, subnet, internet gateway, security group, elastic IP, EC2 instance running Ubuntu 24.04 with PHP 8.3. The whole thing. And because it's all Terraform, you can blow it away and recreate it whenever you want.

Let's get into it.

Server rack in a modern data center Photo by Brett Sayles on Pexels

What We're Building

Here's what Terraform is going to create:

  • VPC with a 10.0.0.0/16 CIDR block. Your own isolated network.
  • Public subnet (10.0.1.0/24) where the EC2 instance lives
  • Internet gateway so your stuff can actually reach the internet
  • Route table with a default route pointing out through the internet gateway
  • Security group allowing HTTP and HTTPS from anywhere, SSH locked to your IP only
  • EC2 instance (t3.micro) on Ubuntu 24.04 LTS with PHP 8.3, Nginx, and Composer
  • Elastic IP so your server keeps the same public IP even if you stop and restart it

That's about 15 resources total. Each one is pretty simple on its own. And once you write this config once, spinning up identical environments takes like two minutes.

Architecture diagram showing cloud network components Photo by Ivan S on Pexels

Prerequisites

You'll need a few things before we start:

  • AWS account with an IAM user that has programmatic access. Access key and secret key. Don't use root for this, seriously.
  • Terraform 1.6 or newer on your local machine. terraform.io if you need it.
  • A Laravel app in a public Git repo. We'll clone it onto the instance during setup.
  • An EC2 key pair from the AWS console (EC2 > Key Pairs). Download the .pem file. You're going to need it for SSH and you can't download it again later.
  • Basic terminal comfort. You don't need to know Terraform. But you should be able to run commands without panicking.

And just to be clear on this, Terraform runs on your local machine. It talks to AWS over the API. We're not installing Terraform on the server.

Project Structure

Create a new directory for your Terraform files. I like to split things up like this:

mkdir terraform-laravel && cd terraform-laravel
terraform-laravel/
├── main.tf           # provider + terraform block
├── variables.tf      # input variables
├── terraform.tfvars  # your actual values (gitignore this)
├── vpc.tf            # VPC, subnet, IGW, route table
├── security_group.tf # security group rules
├── ami.tf            # AMI data source lookup
├── instance.tf       # EC2 + user_data
└── outputs.tf        # elastic IP output

Honestly, you could put everything in one big main.tf and Terraform wouldn't care. It doesn't actually matter what the files are named. But I've been woken up at 2am enough times to know that scrolling through a 300-line file looking for one security group rule is not fun. So I split them.

Let's start filling these in.

Provider and Variables

First, main.tf. This tells Terraform which provider we're using:

terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

Now variables.tf. These are the inputs so you can reuse this config without changing the actual resource definitions:

variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

variable "key_name" {
  description = "Name of your EC2 key pair for SSH access"
  type        = string
}

variable "my_ip" {
  description = "Your public IP address for SSH access (e.g. 203.0.113.50)"
  type        = string
}

variable "app_git_repo" {
  description = "Public Git URL of your Laravel application"
  type        = string
}

And terraform.tfvars is where your actual values go. This one should never end up in Git:

aws_region   = "us-east-1"
instance_type = "t3.micro"
key_name     = "my-laravel-key"
my_ip        = "203.0.113.50"
app_git_repo = "https://github.com/your-username/your-laravel-app.git"

I always add terraform.tfvars to .gitignore first thing, before I even write the config. It has your IP in it. And later you might stick database passwords or other stuff in there. Just get it in your .gitignore now and forget about it.

Networking

Networking. I know, not the most exciting part. But if you get this wrong your instance won't be able to reach the internet and you'll spend an hour wondering why apt can't download packages. Ask me how I know.

Here's the full vpc.tf:

resource "aws_vpc" "hrr_vpc" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "hrr-laravel-vpc"
  }
}

resource "aws_subnet" "hrr_public" {
  vpc_id                  = aws_vpc.hrr_vpc.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

  tags = {
    Name = "hrr-laravel-public-subnet"
  }
}

resource "aws_internet_gateway" "hrr_igw" {
  vpc_id = aws_vpc.hrr_vpc.id

  tags = {
    Name = "hrr-laravel-igw"
  }
}

resource "aws_route_table" "hrr_public_rt" {
  vpc_id = aws_vpc.hrr_vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.hrr_igw.id
  }

  tags = {
    Name = "hrr-laravel-public-rt"
  }
}

resource "aws_route_table_association" "hrr_public_rta" {
  subnet_id      = aws_subnet.hrr_public.id
  route_table_id = aws_route_table.hrr_public_rt.id
}

So the VPC is your isolated network. The subnet is a chunk of that network where our instance will sit. The internet gateway is the door to the outside world. Without it, nothing in your VPC can talk to anything external.

The route table is what tells traffic where to go. Our route says "anything going to 0.0.0.0/0, send it through the internet gateway." And then the route table association hooks that route table up to our subnet.

I burned like two hours on this once because I forgot the route table association. Instance had a public IP, security group looked fine, but it couldn't download a single package. The route table was just sitting there, not attached to anything. So yeah, don't skip that part.

Network infrastructure with connected cables Photo by Brett Sayles on Pexels

Security Group

Security group is basically your firewall. Here's security_group.tf:

resource "aws_security_group" "hrr_laravel_sg" {
  name        = "hrr-laravel-sg"
  description = "Allow SSH from my IP, HTTP and HTTPS from anywhere"
  vpc_id      = aws_vpc.hrr_vpc.id

  # SSH - restricted to your IP only
  ingress {
    description = "SSH from my IP"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["${var.my_ip}/32"]
  }

  # HTTP
  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  # HTTPS
  ingress {
    description = "HTTPS from anywhere"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  # All outbound traffic
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "hrr-laravel-sg"
  }
}

SSH is locked down to var.my_ip/32, so only your IP can get in on port 22. Most tutorials just open it to 0.0.0.0/0 and call it a day. Don't do that. Every bot on the internet will start hammering your server within minutes.

If your IP changes because you're at a coffee shop or whatever, just update my_ip in your tfvars and run terraform apply. Takes like 10 seconds. Set it back when you get home.

HTTP and HTTPS are open to everyone. It's a web server. People need to reach it.

AMI Lookup

You could hardcode an AMI ID in your config, but AMI IDs change per region and they get outdated. Better to look it up dynamically. Create ami.tf:

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }

  owners = ["099720109477"] # Canonical (official Ubuntu publisher)
}

This queries the AWS API when you run terraform plan and finds the latest Ubuntu 24.04 LTS AMI from Canonical. most_recent = true means you always get the one with the latest security patches.

The nice thing about this is portability. If you hardcode an AMI from us-east-1 and try to deploy in eu-west-1, it just fails. With a data source, Terraform figures out the right AMI for whatever region you're in. I use this pattern in basically every project now.

EC2 Instance and User Data

This is the big file. instance.tf creates the actual EC2 instance and uses a user_data script to install everything Laravel needs on first boot:

resource "aws_instance" "hrr_laravel" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = var.instance_type
  key_name               = var.key_name
  vpc_security_group_ids = [aws_security_group.hrr_laravel_sg.id]
  subnet_id              = aws_subnet.hrr_public.id

  user_data = <<-EOF
    #!/bin/bash
    set -e

    # Update system packages
    apt-get update -y
    apt-get upgrade -y

    # Install prerequisites
    apt-get install -y software-properties-common curl unzip git

    # Add PHP 8.3 repository
    add-apt-repository -y ppa:ondrej/php
    apt-get update -y

    # Install PHP 8.3 and extensions
    apt-get install -y \
      php8.3-fpm \
      php8.3-cli \
      php8.3-mbstring \
      php8.3-xml \
      php8.3-bcmath \
      php8.3-curl \
      php8.3-pgsql \
      php8.3-zip \
      nginx

    # Install Composer
    curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

    # Clone the Laravel app
    git clone ${app_git_repo} /var/www/laravel

    # Install dependencies
    cd /var/www/laravel
    composer install --no-dev --optimize-autoloader

    # Set up environment
    cp .env.example .env
    php artisan key:generate

    # Set permissions
    chown -R www-data:www-data /var/www/laravel
    chmod -R 755 /var/www/laravel/storage

    # Configure Nginx
    cat > /etc/nginx/sites-available/laravel <<'NGINX'
    server {
        listen 80;
        server_name _;
        root /var/www/laravel/public;

        index index.php index.html;

        location / {
            try_files \\(uri \\)uri/ /index.php?\$query_string;
        }

        location ~ \.php$ {
            fastcgi_pass unix:/run/php/php8.3-fpm.sock;
            fastcgi_param SCRIPT_FILENAME \\(realpath_root\\)fastcgi_script_name;
            include fastcgi_params;
        }

        location ~ /\.(?!well-known).* {
            deny all;
        }
    }
    NGINX

    # Enable the site
    ln -sf /etc/nginx/sites-available/laravel /etc/nginx/sites-enabled/laravel
    rm -f /etc/nginx/sites-enabled/default

    # Restart services
    systemctl restart php8.3-fpm
    systemctl restart nginx
  EOF

  tags = {
    Name = "hrr-laravel-server"
  }
}

resource "aws_eip" "hrr_laravel_eip" {
  instance = aws_instance.hrr_laravel.id
  domain   = "vpc"

  depends_on = [aws_internet_gateway.hrr_igw]

  tags = {
    Name = "hrr-laravel-eip"
  }
}

The user_data script runs once when the instance boots up for the first time. It installs PHP 8.3 with all the extensions Laravel needs, sets up Nginx, installs Composer, clones your repo, and configures everything. When it finishes, your app is live.

The elastic IP at the bottom gives you a static public address. Without it, the IP changes every time you stop and start the instance. That gets annoying fast. The depends_on just makes sure the internet gateway is there before we try to allocate the EIP.

Quick note on production use. This setup uses SQLite by default, or whatever your .env.example specifies. For a real production app you'd want RDS for the database. And you'd probably want to pull secrets from Secrets Manager instead of having a .env file just sitting on disk. Actually, I've been meaning to write a follow-up post about that exact setup. But for getting started and testing your deployment, this is solid.

Cloud server deployment and computing infrastructure Photo by Christina Morillo on Pexels

Outputs

One more file. outputs.tf prints the stuff you actually need after Terraform finishes:

output "public_ip" {
  description = "Elastic IP address of the Laravel server"
  value       = aws_eip.hrr_laravel_eip.public_ip
}

output "ssh_command" {
  description = "SSH command to connect to the server"
  value       = "ssh -i ~/.ssh/\({var.key_name}.pem ubuntu@\){aws_eip.hrr_laravel_eip.public_ip}"
}

output "app_url" {
  description = "URL to access the Laravel application"
  value       = "http://${aws_eip.hrr_laravel_eip.public_ip}"
}

When terraform apply completes, you'll see the IP address, a ready-to-paste SSH command, and the URL to hit your app. No clicking around in the AWS console.

Deploying

Let's actually run this thing. First, initialize Terraform. This downloads the AWS provider plugin:

terraform init

Next, preview what Terraform is going to do:

terraform plan

You should see something like "Plan: 8 to add, 0 to change, 0 to destroy." Look through the output real quick. VPC, subnet, internet gateway, route table, association, security group, instance, elastic IP. All there? Good.

If the plan looks right, apply it:

terraform apply

Type yes when it asks. Terraform starts creating resources. The whole thing takes about 60 to 90 seconds in my experience. Maybe a bit longer depending on the region.

Apply complete! Resources: 8 added, 0 changed, 0 destroyed.

Outputs:

public_ip   = "54.210.123.45"
ssh_command = "ssh -i ~/.ssh/my-laravel-key.pem ubuntu@54.210.123.45"
app_url     = "http://54.210.123.45"

Now here's the thing. The EC2 instance is running, but the user_data script is still doing its thing in the background. It needs to install PHP, Nginx, Composer, clone your app. Give it 2 to 3 minutes. If you hit the URL too early you'll get a connection refused or the default Nginx page. Just wait.

Technology deployment and launch process Photo by Jeswin Thomas on Pexels

Accessing Your App

After a couple minutes, open http://<your-elastic-ip> in your browser. You should see the Laravel welcome page. This cost me about $0.01 per hour on a t3.micro in us-east-1, by the way. So don't stress about leaving it running for a bit while you test.

If you need to SSH in and poke around:

ssh -i ~/.ssh/my-laravel-key.pem ubuntu@54.210.123.45

Swap in your actual key path and IP. Terraform printed the full command in the outputs so you can literally just copy paste it.

Your Laravel app lives at /var/www/laravel. Nginx logs are in /var/log/nginx/. If you're getting a 502 Bad Gateway, check if PHP-FPM is running:

sudo systemctl status php8.3-fpm

If it's not active, restart it and nginx:

sudo systemctl restart php8.3-fpm
sudo systemctl restart nginx

You can also check what happened during the initial setup by looking at the cloud-init log:

sudo cat /var/log/cloud-init-output.log

Everything the bootstrap script printed ends up in that file. If a package failed to install or Composer threw an error, it'll be in there.

Cleanup

When you're done, tear it all down:

terraform destroy

Type yes and Terraform deletes everything. Elastic IP, instance, security group, route table, gateway, subnet, VPC. All of it. Takes about 60 seconds.

This is honestly one of my favorite things about using Terraform for this stuff. No surprise bills next month because you forgot about a running instance. And the elastic IP gets released too. AWS charges you for elastic IPs that aren't attached to anything, so cleaning up properly actually saves you money.

Want to bring it all back? Just run terraform apply again. Same config, same result.

Wrapping Up

Look, EC2 is still a perfectly good way to run a Laravel app on AWS. Not everything needs containers or Lambda. Sometimes simple is the right answer. And with Terraform you can version control the whole setup and recreate it whenever.

What we built today is a solid starting point. You could add RDS for a proper database, throw a load balancer in front for SSL termination, or use ACM for a free TLS certificate. All of that is just more Terraform on top of what you already have. Actually, I've been meaning to try Terragrunt for managing multiple environments but haven't gotten around to it yet.

Hope you enjoyed this one. If you have questions or want to show me what you built on top of this setup, find me on Twitter at https://x.com/harundotdev. Always happy to chat about this stuff.

Get the next note

I email when a new post goes up. One send a week, and only if there's something new.

Want this applied on your account? Start with Infrastructure as Code.

Related reading

More posts, sliding underneath the article

Kept below the post instead of in a sidebar, with a slow continuous motion for a cleaner editorial feel.

On this post

Comments

A reply stays under the note it answers.

0 comments

No comments yet.

If you have a note on Deploying a Laravel Application to Amazon EC2 Using Terraform, sign in and leave it.