Deploying Laravel on Ubuntu

August 7, 2023

We are going to walk through all the steps required to get from a blank Unbuntu server to running our Laravel application.

This post is as much for me as it is for you. I should preface this that this is in no way my recommendation for how you should deploy your production Laravel application. There are tools that handle these things for you without you having to worry about them. This is moreso an exercise in understanding all that goes into it. Like me, I’m sure you’ll come out of it with a better understanding, and a newfound appreciation for what these deployment tools and platforms are doing for us.

Linux setup

Create a server on whatever platform you choose. Choose an LTS version of Ubuntu.

I’m spinning mine up on Digital Ocean.

SSH login key

Generate or use an existing SSH Key (locally).

1ssh-keygen -t rsa
2cat | pbcopy

Log in to your new droplet as root with the chosen SSH key.

Provide your key name and IP address.

1cd ~/.ssh
2ssh -i id_rsa root@<ip_address>

Create a user

Create user:

  1. create new user
  2. add user to sudo group
  3. switch user to new user
  4. check sudo access of new user
1adduser clint
2usermod -aG sudo clint
3su clint
4sudo cat /var/log/auth.log

To add ssh access to the user, add the public ssh key to /home/clint/.ssh/authorized_keys (it won’t exist, so create it).


1cat ~/.ssh/is_rsa | pbcopy


1echo "<ssh_key>" >> ~/.ssh/authorized_keys
2chmod 644 ~/.ssh/authorized_keys
3sudo service sshd restart

Open a new tab and attempt to log in as the new user.

1ssh -i id_rsa clint@<ip_address>

If successful, we can revoke root login.

Important: Check that you can log in as new user and have sudo access before disabling root access!

1sudo vim /etc/ssh/sshd_config

Change PermitRootLogin yes to PermitRootLogin no.

Project Environment


We still need composer, PHP, MySQL, etc. Let’s figure out how to get all that:


There’s a lot happening in the following subsections that cover each and every depedency needed to get PHP up and running. I’m dropping a reference here so you can speed through it the next time you’re here:

1sudo add-apt-repository ppa:ondrej/php
3sudo apt update && sudo apt upgrade
5sudo apt install php8.2 php-curl php-mbstring php-pear php-fpm php-zip
7# check php is installed at the correct version
8php -v

Crazy that most of the work that took me a long time below amounted to adding a few more extensions to the initial PHP install to round it out.


We don’t want to have to build from source, so we can use a PPA (non-official) ubuntu package for installing PHP:

Typically, installing a PPA happens in 3 steps:

  1. Add the PPA repo to your system so we know how to download it: sudo add-apt-repository ppa:ondrej/php
  2. Update the packages so its up to date before downloading: sudo apt update && sudo apt upgrade
  3. Install the package: sudo apt install php8.2
  4. which php should output /usr/bin/php and php -v should give you PHP 8.2.X

Here’s the list of dependencies from the laravel site:

  • PHP >= 8.1 - we just installed it PHP >= 8.1 - we just installed itPHP >= 8.1 - we just installed itPHP >= 8.1 - we just installed itPHP >= 8.1 - we just installed itPHP >= 8.1 - we just installed itPHP >= 8.1 - we just installed itPHP >= 8.1 - we just installed itPHP >= 8.1 - we just installed itPHP >= 8.1 - we just installed itPHP >= 8.1 - we just installed it
  • Ctype PHP Extension
  • cURL PHP Extension
  • DOM PHP Extension
  • Fileinfo PHP Extension
  • Filter PHP Extension
  • Hash PHP Extension
  • Mbstring PHP Extension
  • OpenSSL PHP Extension
  • PCRE PHP Extension
  • PDO PHP Extension
  • Session PHP Extension
  • Tokenizer PHP Extension
  • XML PHP Extension

We just installed PHP. Let’s now figure out how to enable all of these extensions.

Type php --ini to get a nice breakdown of your PHP configuration. Mine looks like this:

1Configuration File (php.ini) Path: /etc/php/8.2/cli
2Loaded Configuration File: /etc/php/8.2/cli/php.ini
3Scan for additional .ini files in: /etc/php/8.2/cli/conf.d
4Additional .ini files parsed: /etc/php/8.2/cli/conf.d/10-opcache.ini,

From this output, we can learn that our php.ini file is located in /etc/php/8.2/cli/php.ini. So we should be able to edit that to load extensions.

We can also see that it attempts to load additional ini files from /etc/php/8.2/cli/conf.d where it found a whole list of them. Some appear to overlap with the Laravel requirements, but not all. You’ll also notice that the files are prefixed with numbers like 10 and 20. I think I learned this while trying to setup xDebug, the numbers are used to order the files. They are loaded in order, so the order sometimes matters if extensions depend on each other. We don’t want to load an extension that requires another extension that wasn’t loaded yet.

The following extensions overlap between the Laravel requirements and the ini list: ctype, fileinfo, pdo, opcache, tokenizer.


Let’s start with ctype. I found this when searching PHP’s site for ctype: Essentially, it’s built in and requires no customization. It also says there is no configuration directives in php.ini. If I search the php.ini file for ctype, nothing comes up. However, we do have this ini file for ctype, so what is that? If we cat the file, we see this:

1; configuration for php common module
2; priority=20

All that does is make sure the built-in extension is included, so looks like we are good to go.


Fileinfo is the same story. It does, however, have a commented out line in php.ini for loading the extension ;extension=fileinfo. However, it also has its own ini that may load the extension. How can we find out? Well php can output an information page that tells us the result of PHP’s configurations. If we type php -i we can see its entire breakdown. If we type php -i | grep fileinfo we can see the relevant information.

3fileinfo support => enabled

We see it’s enabled.


Let’s speed this up and keep grepping for the various extensions.

1php -i | grep pdo
3# /etc/php/8.2/cli/conf.d/10-pdo.ini,
5php -i | grep PDO
7# PDO
8# PDO support => enabled
9# PDO drivers =>



1php -i | grep opcache
3# ...
4# opcache.enable => On => On
5# opcache.enable_cli => Off => Off
6# ...

So we see it’s on.


1php -i | grep -i tokenizer
3# /etc/php/8.2/cli/conf.d/20-tokenizer.ini
4# tokenizer
5# Tokenizer Support => enabled

That covers all of the extensions that had ini files.


Let’s see if curl is enabled. We know we have curl on the server by typing which curl and getting /usr/bin/curl. From PHP site: “…As of PHP 7.3.0, version 7.15.5 or later is required. As of PHP 8.0.0, version 7.29.0 or later is required.”

1curl -V
3# curl 7.81.0 ...

We’re good!

However, we don’t appear to have curl as an extension. No output when doing php -i | grep -i curl.

There’s a line for extension=curl in the php.ini, but when we uncomment it, we get the following error when running any PHP commands.

1PHP Warning: PHP Startup: Unable to load dynamic library 'curl' (tried: /usr/lib/php/20220829/curl (/usr/lib/php/20220829/curl: cannot open shared object file: No such file or directory), /usr/lib/php/20220829/ (/usr/lib/php/20220829/ cannot open shared object file: No such file or directory)) in Unknown on line 0

Looks like what we need to do is install php-curl from the PPA as well. So we can run sudo apt update and sudo apt install php-curl. If we output php --ini again, we will actually see /etc/php/8.2/cli/conf.d/20-curl.ini in the list of ini files parsed.

Re-comment out the extension in our php.ini so we aren’t loading it twice. Now we have this:

1php -i | grep -i curl
3# /etc/php/8.2/cli/conf.d/20-curl.ini,
4# curl
5# cURL support => enabled
6# cURL Information => 7.81.0
7# curl.cainfo => no value => no value



Looks like DOM requires libxml:

If we grep for xml in our phpinfo output, we see:

2libXML support => active
3libXML Compiled Version => 2.9.14
4libXML Loaded Version => 20914
5libXML streams => enabled

So that seems okay.

We don’t see anything about DOM though.

The docs specify it is enabled by default without any runtime configurations. So we should be good to go 🤞.


Installed by default.


Built into PHP core.


Must be installed.

1sudo apt update
2sudo apt install php-mbstring

/etc/php/8.2/cli/conf.d/20-mbstring.ini is added to the list of loaded ini files automatically.

1php -i | grep -i mbstring
3# /etc/php/8.2/cli/conf.d/20-mbstring.ini,
4# Zend Multibyte Support => provided by mbstring
5# Multibyte decoding support using mbstring => enabled


Appears to be enabled by default.

1php -i | grep -i openssl
3# SSL Version => OpenSSL/3.0.2
4# libSSH Version => libssh/0.9.6/openssl/zlib
5# openssl
6# OpenSSL support => enabled


Appears to be enabled by default.

1php -i | grep -i pcre
3# pcre
4# PCRE (Perl Compatible Regular Expressions) Support => enabled


Appears to be enabled by default.

1php -i | grep -i session
3# session
4# Session Support => enabled


We have libxml for the DOM package we added earlier, but I think this xmldiff pecl extension is something else.

I don’t think we have that one.

Since it’s a pecl extension, I think we have to install pear first: sudo apt install php-pear.

Running that install also installed php-xml alongside it, which may have added some XML package for us. That may be what we needed.


The nginx config provided by the Laravel docs has a file path to an fpm file, but we don’t have it. We need to install php-fpm via sudo apt install php-fpm.


This package is used by composer for downloading packages.

1sudo apt install php-zip


We are now out of the woods of setting up our PHP system.

Let’s install Nginx.

1sudo apt install nginx
2sudo service nginx start

Add an nginx config file to /etc/nginx/sites-enabled/. E.g. /etc/nginx/sites-enabled/ Refer to the Laravel docs for what a standard Laravel application nginx config file should look like.

Include it in /etc/nginx/nginx.conf at the bottom of the http block, and comment out the wildcard include.

Create a directory for the project being served. E.g. /var/www/

Change your nameserver host from Namecheap to DigitalOcean for your domain name. That means ns1/2/ records are added to the domain on Namecheap. Then on DigitalOcean, add the A records that point from the domain to the droplet IP.

This sample server file should allow you to get up and running. Put it in /etc/nginx/sites-enabled/

1server {
2 listen 80 default_server;
3 listen [::]:80 default_server;
4 server_name *;
5 root /var/www/;
7 index index.php;
9 charset utf-8;
11 location / {
12 try_files $uri $uri/ /index.php?$query_string;
13 }
15 error_page 404 /index.php;
17 location ~ \.php$ {
18 fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
19 fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
20 include fastcgi_params;
21 }
23 location ~ /\.(?!well-known).* {
24 deny all;
25 }


Now we need to clone the actual project repo onto the server.

To do that, we need to be authorized to do so from the server.

Option 1: new SSH key on server

We need to generate another SSH key and register it with our Github account in order to allow us to clone it.

1ssh-keygen -t rsa

cat the file and copy the contents into a new ssh key in your github profile:

That works as-is from my perspective, although there are other things you may want to do. Refer to Github’s docs on SSH keys to do other stuff.

Option 2: SSH Agent forwarding

Alternatively, instead of creating a new key on every server, we can use our existing local key via agent forwarding. When you ssh into a server, you can share your local keys with the host. All you have to do is add this to ~/.ssh/config (just replace with the domain or IP of your server):

2 ForwardAgent yes

To test if it works, I renamed id_rsa to id_foo and ran ssh -T [email protected] (which tests the SSH key). It failed.

Then I performed the above step and added the server IP to my config file. When I re-SSH’d into the server, it worked again.

Let’s now clone in our repo and set up proper permissions.

1cd /var/www/
2git clone [email protected]:Username/repository.git .
3cd ..
4sudo chown www-data:www-data -R

If we don’t use the www-data:www-data user/group, we are likely to hit a permissions error when visiting the app.

Laravel project setup

Next we need to set up our actual Laravel application.


First, we need to install composer. Follow the instructions for installing it for your user (doesn’t require sudo):

  • mkdir -p ~/.local/bin/
  • mv composer.phar .local/bin/composer
  • Use vim .bashrc to add the line export PATH="$PATH:$HOME/.local/bin"
  • source .bashrc to reload the config
  • composer to see if composer is now an accessible command

Before we install with composer, there’s another PHP extension that is useful:

Next, run composer install --no-dev -o to install composer packages for production.


Now we need to create a .env file. Clone .env.example via cp .env.example .env.

Next, generate an application key with php artisan key:generate, which will automatically update the .env file.

Turn on maintenance mode with a secret key: php artisan down --secret="my-secret-key".

Visit and you should now see the Laravel homepage (or whatever your / route is)!

That should pretty much do it for getting started with setting up a Laravel application.

The rest of the dependencies are up to you to setup.

Typically you’ll need a database, a queue driver, cacheing mechanism, etc.


Well… that was a heck of a lot of work, huh?

Let’s recap.

We spun up a server on DigitalOcean.

We enabled login via SSH.

We disabled root login and created a user for security.

We set up our project environment by installing PHP and Nginx.

We connected to Github and cloned in our project.

Then we set up the project by creating an env file and installing composer and the project’s dependencies.

And this is just the beginning. We haven’t event set up all of the typical Laravel dependencies like a database, cache, queue, mail, monitoring, bug logging, and more.

There’s also more we can do:

  • We can take steps to make the server more secure like using a firewall to ensure unused ports are disabled.
  • We can do primitive CI/CD to update the project when master is updated.
  • We can ensure we keep the server up-to-date and healthy automatically.

It’s no wonder cloud and container solutions have become so popular. Doing this stuff is annoying and kind of a waste of your precious time as someone who wants to build things.