Deploy a laravel app from scratch on ubuntu

Apr 16 / 6 min read

Before you go through the pain and challenge of configuring your own server you should perhaps consider Laravel forge. I trust they would know better how to deploy a laravel app.

For curious minded people, this is part of a bigger series Do it yourself series

Components

  • webserver
    • nginx
  • database
    • mysql
  • php
  • composer
  • node
  • npm / yarn
  • scheduler
  • firewall
  • log
    • papertrail
  • search
    • elastic search
    • algolia
  • other third party services
    • redis

Basic build

recepee on an ubuntu server (22-10)

  • mysql
  • php
  • composer
  • node
  • nginx

Requirements

  • VPS Server
  • Valid DNS record pointing to your server

Mysql

sudo apt-get install -y mysql-server

# Init project by creating a user with a dedicated database
name=$1
username=${2:-$name}
password=${3:-$name}
root_username=${4:-'root'}
root_password=${5:-''}
echo '' > tmp.sql
echo "CREATE USER $name@localhost identified by \"$password\";" >> tmp.sql
echo "CREATE DATABASE $name charset utf8 collate utf8_general_ci;" >> tmp.sql
echo "GRANT ALL PRIVILEGES ON $name.* to $name@localhost;" >> tmp.sql
mysql -u$root_username -p$root_password -e "source tmp.sql"


mysql -u$root_username -p$root_password -e "CREATE DATABASE $name charset utf8 collate utf8_general_ci;"

Php

sudo apt install -y software-properties-common
sudo add-apt-repository ppa:ondrej/php
sudo apt update

# php with some extensions
PHP_VERSION=${1:-'8.2'}
sudo apt-get install -y "php$PHP_VERSION" php$PHP_VERSION-{common,cli,fpm,zip,xml,pdo,mysql,mbstring,tokenizer,ctype,curl,common,curl,gd,intl,sqlite3,xmlrpc,xsl,soap,opcache,readline,xdebug,bcmath}

Composer

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"

# For simplicity purpose, we are skipping the hash check. That is a crucial step you wouldn't want to skip when downloading stuff on the internet
# Hash below matches composer version 2.1.3
# php -r "if (hash_file('sha384', 'composer-setup.php') === '756890a4488ce9024fc62c56153228907f1545c228516cbf63f885e036d37e9a59d27d63f46af1d4d07ee0f76181c7d3') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"
sudo mv composer.phar /usr/local/bin/composer

Node

You can get node on their website but I prefer getting specific version from node version manager (nvm)

version=${1:-'20'}
echo "Installing nvm + node $version"
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
source ~/.bashrc
nvm install $version
# Optional but I like yarn so here we go
nvm exec $version npm i yarn -g

Nginx

# Making sure apache is not installed to avoid conflict on port 80
sudo apt-get remove -y apache
sudo apt-get install -y nginx
rm /etc/nginx/sites-available/default  /etc/nginx/sites-enabled/default 

name=$1 # site.domain
webroot=${3:-"/var/www/vhosts/$name"}
mkdir -p $webroot
touch "/etc/nginx/sites-available/$name.conf"
ln -s "/etc/nginx/sites-available/$name.conf" "/etc/nginx/sites-enabled/$name.conf"
cat >> "/etc/nginx/sites-available/$name.conf" << EOF

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    root $webroot;
    server_name $name www.$name;
}
EOF

Secure traffic using HTTPS

At this point we are running a web server on port 80. However everyone can see the content flowing through the networking. Welcome https.

domain=$1
email=${2:-"[email protected]"}
sudo apt-get install -y python3-certbot-nginx


# manual
# certbot certonly -a manual --rsa-key-size 4096 --email $email -d $domain -d www.$domain

# auto
## With base nginx config
certbot certonly --nginx --rsa-key-size 4096 --email $email -d $domain -d www.$domain

When the steps above succeed you can carry on with nginx config for ssl. The following will redirect all non secure connection (80) to secure connection (443)

# Usage ./laravel.sh site.domain 8.2 ~/sites
## Dependencies: letsencrypt, php$php_version, php$php_version-fpm

# sudo apt-get install -y php$php_version php$php_version-fpm
name=$1 # site.domain
user=$2
php_version=${3:-'8.2'}
root=${4:-"/var/www/vhosts/$name"}
webroot=${5:-"/var/www/vhosts/$name/public"}
touch /etc/nginx/sites-available/$name.conf
ln -s /etc/nginx/sites-available/$name.conf /etc/nginx/sites-enabled/$name.conf

mkdir -p /var/www/vhosts/$name/storage/logs
touch /var/www/vhosts/$name/storage/logs/error.log
touch /var/www/vhosts/$name/storage/logs/access.log

cat >> /etc/nginx/sites-available/$name.conf << EOF

# Force HTTPS
server {
  listen 80;
  listen [::]:80;
  server_name $name www.$name;
  return 301 https://$name\$request_uri;
}

# Force www less version'
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name www.$name;
    ssl_certificate /etc/letsencrypt/live/$name/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/$name/privkey.pem;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
    return 301 https://$name\$request_uri;
}


server {
    server_name $name;

    # SSL config
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_tokens off;
    ssl_buffer_size 8k;
    ssl_protocols TLSv1.3 TLSv1.2;
    ssl_prefer_server_ciphers on;
    ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5;
    ssl_ecdh_curve secp384r1;
    ssl_session_tickets off;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8 8.8.4.4;

    ssl_certificate /etc/letsencrypt/live/$name/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/$name/privkey.pem;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
    # End of SSL config


    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header X-XSS-Protection "1; mode=block";

    charset utf-8;

    index index.php index.html;
    error_log $root/storage/logs/error.log;
    access_log $root/storage/logs/access.log;
    root $webroot;
    error_page 404 /index.php;


    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";


    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    location / {
        try_files \$uri \$uri/ /index.php?\$query_string;
        gzip_static on;

        proxy_set_header        Host \$server_name;
        proxy_set_header        X-Real-IP \$remote_addr;
        proxy_set_header        X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header        X-Forwarded-Proto \$scheme;
    }

    location ~ \.php$ {
        try_files \$uri /index.php =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/run/php/php$php_version-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
        fastcgi_param PATH_INFO \$fastcgi_path_info;

        fastcgi_buffers 8 64k;
        fastcgi_buffer_size 128k;
        fastcgi_connect_timeout 3000;
        fastcgi_send_timeout 3000;
        fastcgi_read_timeout 3000;
    }

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

    # Define caching rules for static images
    location ~* \.(jpg|jpeg|png|gif|ico)$ {
        expires 30d; # adjust the caching duration as needed
        add_header Cache-Control "public, max-age=2592000";
    }

    gzip on;
    gzip_types text/plain text/css application/javascript image/*;

    client_max_body_size 128m;
    # For unlimited
    # client_max_body_size 0;
}
EOF

# permissions
sudo find storage -type f -exec chmod 664 {} \;
sudo find storage -type d -exec chmod 775 {} \;
sudo chmod -R ug+rwx storage bootstrap/cache
sudo chgrp -R www-data storage bootstrap/cache
sudo usermod -aG $user www-data
sudo chown $user:www-data -R storage bootstrap/cache

# Check file exists
# /etc/ssl/dhparams.pem
# Generate if not
# openssl dhparam -out /etc/ssl/dhparams.pem 4096


cat >> /etc/php/$php_version/cli/php.ini << EOF
post_max_size=128M
upload_max_filesize=128M
max_upload_file=50
EOF
Auto renew certificate
# DISCLAIMER: it is safer to edit cron file using crontab dedicated command
# That being see given this is a script we likely want to have automated
/var/spool/cron/crontabs

## cron job to auto renew every 3 months for you
crontab -e
# 0 0 1 */3 * /usr/bin/certbot renew --quiet

## I saw people doing it monthly
# 0 0 1 * *

Conclusion

No conclusion as this is a starting point only but gets you an operational laravel app. Made a lot of arbitrary choices. Adapt for your usecase. Next up

  • automate script using orchestration (ie ansible)
  • deploy using aws code deploy
  • CI / CD pipeline from github
  • tighting security with firewall policies
  • monitoring
  • load management / balancing