skip to Main Content
Laravel Forge On Custom VPS – Web Server & Database Server

To set up Laravel Forge on Custom VPS you need to follow the custom VPS route. First you need to make the VPS Server ready with the operating system of choice, then you need to set up passwordless ssh access for root and then you need to tell Laravel Forge to set up the Laravel LEMP. Here is how we did it on a Transip Blade VPS.

VPS Choice & Initial Step

We got a Transip VPS Blade 4GB VPS with 2 cores. We installed Ubuntu 16.0.4 on it. At Transip you have to go through all the steps to set things up. Things like language:

Ubuntu 16.0.4 Setup

host name:



have to be chosen. You also need to:

  • add a user for non administrative tasks,
  • choose encryption for home directory or not,
  • pick  a partitioning option
  • and so on.
  • automatic (security) updates or not

Software Packages

You also need to choose the software package to install. As we use Forge to set all up we chose

  • basic system utilities,
  • basic server package
  • and an Openssh Server*

if not installed this can be done with apt install openssh-server

Basically you set up a Ubuntu server from scratch. On Digital Ocean there is no need. And a ssh key for root access is added right away. That is not the case at Transip.

SSH Root Access

You need root access by Laravel Forge. This as you need to run the shell script as root. When you setup openssh-server it normally is setup with root blocked. So login with the user you setup, then do a

sudo nano /etc/ssh/sshd_config

then make sure you have

PasswordAuthentication yes
PermitRootLogin yes

, save and restart the openssh server with

sudo service ssh restart

Then do a sudo passwd to set a root password and

ssh-copy-id -i ~/.ssh/

to copy over your ssh public key. You could now revert password access for root and or for your setup user, but Laravel Forge should take care of at least the root part.

NB Perhaps this part can be shortened as less may be needed. Feel free to let me know.

Forge VPS Configuration

Pick a custom server first of course. Also, we assume you added your public key already. Choose a:

  • name
  • ip address
  • ram (must be a number by the way!)
  • php version
  • post provision script if any
  • database version

Laravel Custom VPS

We picked a regular server without database as we will setup a database server later on.

Forge Shell Script

Then on clicking create you will get a popup with a script you should run on your server

wget -O ""; bash

I removed the key here of course. Well this script does a whole bunch of stuff. The script will eventually be stored on the actual server. It is basically a full fledged setup of all you need. In my case a full LEMP without a database.

Forge User

Once this script has run its course forge has been set up as user. So you can log off as root and enter using

Welcome to Ubuntu 16.04.4 LTS (GNU/Linux 4.4.0-116-generic x86_64)
 * Documentation:
 * Management:
 * Support:
3 packages can be updated.
0 updates are security updates.
To run a command as administrator (user "root"), use "sudo ".
See "man sudo_root" for details.

Laravel Provision Check

When all went well the script will show no errors. You will also see that forge has been added as user as discussed and root shell access will also be blocked again:

ssh Permission denied (publickey).

They do this running:

# Disable Password Authentication Over SSH
sed -i "/PasswordAuthentication yes/d" /etc/ssh/sshd_config
echo "" | sudo tee -a /etc/ssh/sshd_config
echo "" | sudo tee -a /etc/ssh/sshd_config
echo "PasswordAuthentication no" | sudo tee -a /etc/ssh/sshd_config

You can also check if Laravel Forge can make the connection properly clicking the connection icon:

Laravel Forge Connection Check

Forge Setup Email

Once you have connected properly you will also get an email that you did with details on forge and its password:

Your Laravel Forge server has been provisioned!

Remember, before you can access your server, you will need to add a public key via the Laravel Forge server management page. For added security, password authentication over SSH is disabled by default on all Laravel Forge servers.

Server Details:

Name: name-1
IP Address:
Username: forge
Sudo Password: password 

Default Site

We now have the general server up and running. We have however not set up the site, nor have we deployed our Laravel application. There is a however a default server block / site with this nginx configuration

include forge-conf/default/before/*;

server {
 listen 80 default_server;
 listen [::]:80 default_server;
 server_name default;
 root /home/forge/default/public;

 # ssl_certificate;
 # ssl_certificate_key;

ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
 ssl_ciphers 'YOURKEY';
 ssl_prefer_server_ciphers on;
 ssl_dhparam /etc/nginx/dhparams.pem;

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

index index.html index.htm index.php;

charset utf-8;

 include forge-conf/default/server/*;

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

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

access_log off;
 error_log /var/log/nginx/default-error.log error;

error_page 404 /index.php;

location ~ \.php$ {
 fastcgi_split_path_info ^(.+\.php)(/.+)$;
 fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
 fastcgi_index index.php;
 include fastcgi_params;

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

include forge-conf/default/after/*;

Its root is


Forge however reminds you to remove it once you have set up your main site:

Think of sites as representing each “domain” on your server.
The “default” site is included with each freshly provisioned server; however, you should delete it and create a new site with a valid domain name when you are ready to launch your production site. If you need to host additional domains or sub-domains, add them here.

Adding a New Site

We want to add a subdomain and the main site.

Laravel Forge Add New Site

The main domain we allowed with wildcard sub-domains. For the subdomain we did not. So now we had two server blocks or sites setup. Default was still there:

forge@Name-1:/etc/nginx/sites-available$ ll
total 24
drwxr-xr-x 2 root root 4096 Feb 24 13:16 ./
drwxr-xr-x 9 root root 4096 Feb 24 12:42 ../
-rw-r--r-- 1 root root   27 Feb 24 12:41 catch-all
-rw-r--r-- 1 root root 2092 Feb 24 12:42 default
-rw-r--r-- 1 root root 2104 Feb 24 13:16
-rw-r--r-- 1 root root 2087 Feb 24 13:15

Root for the or main domain is root /home/forge/ For the subdomain it is /home/forge/ The catch-all has

server {
return 404;

It has no location, it sends out a 404 for anything that is not matched in the other server blocks.

Deployer Deployment Path

For deployment we use’s Deployer. We have a script that can deploy on beta and on production. See the link for some details on our deployment script. For more detail on Deployer setup see their site. You do however need to adjust your Nginx’s root as we need it to be /home/forge/ not /home/forge/ This way you can deal with releases and current release. Also restart your Nginx server.You can do this from the Laravel Forge Control panel This way the release will be served properly:

dep deploy production
✔ Executing task deploy:prepare
✔ Executing task deploy:lock
✔ Executing task deploy:release
➤ Executing task deploy:update_code
Username for '': user
Password for '': 
Counting objects: 16466, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (7983/7983), done.
Writing objects: 100% (16466/16466), done.
Total 16466 (delta 7946), reused 16466 (delta 7946)
Connection to closed.
✔ Ok
✔ Executing task deploy:shared
✔ Executing task deploy:vendors
✔ Executing task deploy:writable
✔ Executing task artisan:storage:link
✔ Executing task artisan:view:clear
✔ Executing task artisan:cache:clear
✔ Executing task artisan:config:cache
✔ Executing task artisan:optimize
✔ Executing task deploy:symlink
✔ Executing task db:migrate
✔ Executing task cachetool:clear:opcache
✔ Executing task deploy:unlock
✔ Executing task cleanup
✔ Executing task success
Successfully deployed!

Once this is done you will see the Laravel Error Whoops, looks like something went wrong.. This as we do not have a database (server) setup nor have we added our .env file.

Adding your .env file

Adding your .env file like editing your nginx configuration can be done from the Laravel Forge control panel under site details

Laravel Forge Site Details

Perhaps APP_DEBUG=true would be good to debug possible issues as well. We restarted the server to get it all to show the errors.

Now, Deployer loads .env from ~/ . Laravel Forge grabs it from the web root and stores it there. So until we have a solution you cannot edit .env from Laravel Forge as it will edit the one that is not used / loaded.

Once you have added the .env data to the mentioned location you should start serving the site. You will have achieved Laravel with Laravel Forge on Custom VPS. Only the database… We chose to use a separate database server.

Bonus Database Server

Now we want to setup a database server and see if we can manage this with Laravel Forge as well. Set up a new server with just the server basics as indicated at the beginning here. Now instead of using Laravel Forge just go your own way. Forge is more for LEMP setups. We want a database server. So on the new VPS serverrun

sudo apt-get update
sudo apt-get upgrade

and then install the MariaDB server running:

sudo apt-get install mariadb-server

Then you can access MariaDB using

sudo mysql -uroot

And then you can check out the out of the box databases:

Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 46
Server version: 10.0.33-MariaDB-0ubuntu0.16.04.1 Ubuntu 16.04

Copyright (c) 2000, 2017, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]> show databases;
| Database           |
| information_schema |
| mysql              |
| performance_schema |
3 rows in set (0.00 sec)

Securing MariaDB

Now securing and setting a new root password is a good idea so:

sudo mysql_secure_installation

You will then get:


In order to log into MariaDB to secure it, we'll need the current
password for the root user.  If you've just installed MariaDB, and
you haven't set the root password yet, the password will be blank,
so you should just press enter here.

Enter current password for root (enter for none): 
OK, successfully used password, moving on...

Setting the root password ensures that nobody can log into the MariaDB
root user without the proper authorisation.

Set root password? [Y/n] Y
New password: 
Re-enter new password: 
Password updated successfully!
Reloading privilege tables..
 ... Success!

By default, a MariaDB installation has an anonymous user, allowing anyone
to log into MariaDB without having to have a user account created for
them.  This is intended only for testing, and to make the installation
go a bit smoother.  You should remove them before moving into a
production environment.

Remove anonymous users? [Y/n] Y
 ... Success!

Normally, root should only be allowed to connect from 'localhost'.  This
ensures that someone cannot guess at the root password from the network.

Disallow root login remotely? [Y/n] Y
 ... Success!

By default, MariaDB comes with a database named 'test' that anyone can
access.  This is also intended only for testing, and should be removed
before moving into a production environment.

Remove test database and access to it? [Y/n] Y
 - Dropping test database...
 ... Success!
 - Removing privileges on test database...
 ... Success!

Reloading the privilege tables will ensure that all changes made so far
will take effect immediately.

Reload privilege tables now? [Y/n] Y
 ... Success!

Cleaning up...

All done!  If you've completed all of the above steps, your MariaDB
installation should now be secure.

Thanks for using MariaDB!

Now we need to create the database or in our case database AND we we need to allow our web server access .

MariaDB Remote Access

When you read the MariaDB documentation on the bind address you will find out that you need to change this for Ubuntu systems as they use instead of The file you need to change is /etc/mysql/mariadb.conf.d/50-server.cnf. What we can do is add a specific ip address or hostname. Now we are on an Ubuntu 16.0.4 server here so we open


There you will find

# Instead of skip-networking the default is now to listen only on
# localhost which is more compatible and is not less secure.
bind-address =

And they indicate example my.ncf files as well at /usr/share/mysql/. Only we found none..

MariaDB Config Files

Now, we prefer to use our own configuration file and leave the default be. So we look into the documentation of MariaDB and see my.cnf could be in many locations. Then we check our box and find a symlink to the my.cnf:

me@domain-database:/etc/mysql$ ll
total 32
drwxr-xr-x  4 root root 4096 Feb 24 17:57 ./
drwxr-xr-x 93 root root 4096 Feb 24 17:57 ../
drwxr-xr-x  2 root root 4096 Feb 24 18:14 conf.d/
-rw-------  1 root root  277 Feb 24 17:57 debian.cnf
-rwxr-xr-x  1 root root 1426 Jan 16 17:46 debian-start*
-rw-r--r--  1 root root  869 Jan 16 17:46 mariadb.cnf
drwxr-xr-x  2 root root 4096 Feb 25 07:42 mariadb.conf.d/
lrwxrwxrwx  1 root root   24 Feb 24 17:57 my.cnf -> /etc/alternatives/my.cnf
-rw-r--r--  1 root root  839 Jan 21  2017 my.cnf.fallback

and there is a mariadb.cnf as well. The latter just seems to read config files from several locations:

# The MariaDB configuration file
# The MariaDB/MySQL tools read configuration files in the following order:
# 1. "/etc/mysql/mariadb.cnf" (this file) to set global defaults,
# 2. "/etc/mysql/conf.d/*.cnf" to set global options.
# 3. "/etc/mysql/mariadb.conf.d/*.cnf" to set MariaDB-only options.
# 4. "~/.my.cnf" to set user-specific options.

My.cnf Choice

The my.cnf file /etc/alternatives/my.cnf is a replica of the mariadb.cnf file. So the best way seems to add one to /etc/mysql/conf.d/. In that directory we have:

/etc/mysql/conf.d$ ll
total 16
drwxr-xr-x 2 root root 4096 Feb 24 18:14 ./
drwxr-xr-x 4 root root 4096 Feb 24 17:57 ../
-rw-r--r-- 1 root root    8 Jan 21  2017 mysql.cnf
-rw-r--r-- 1 root root   55 Jan 21  2017 mysqldump.cnf

So we can create a my.cnf here.. Well I read some more and did a:

mysql --help

to figure out how the my.cnfs are being loaded:

mysql --help
mysql  Ver 15.1 Distrib 10.0.33-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2
Copyright (c) 2000, 2017, Oracle, MariaDB Corporation Ab and others.

Usage: mysql [OPTIONS] [database]

Default options are read from the following files in the given order:
/etc/my.cnf /etc/mysql/my.cnf ~/.my.cnf

I also did a search for existing my.cnfs:

sudo find / -name my.cnf

From these locations mariadb.cnf does not read. But according to the mysql –help output /etc/mysql/my.cnf should be read. And for that we have the symlink to /etc/alternatives/my.cnf. This has been created by MariaDB. This file has the same content as mariadb.cnf

# The MariaDB configuration file
# The MariaDB/MySQL tools read configuration files in the following order:
# 1. "/etc/mysql/mariadb.cnf" (this file) to set global defaults,
# 2. "/etc/mysql/conf.d/*.cnf" to set global options.
# 3. "/etc/mysql/mariadb.conf.d/*.cnf" to set MariaDB-only options.
# 4. "~/.my.cnf" to set user-specific options.
# If the same option is defined multiple times, the last one will apply.
# One can use all long options that the program supports.
# Run program with --help to get a list of available options and with
# --print-defaults to see which it would actually understand and use.

# This group is read both both by the client and the server
# use it for options that affect everything

# Import all .cnf files from configuration directory
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mariadb.conf.d/

So we have come full circle and I can add a my.cnf to /etc/mysql/conf.d/. So we did. Here is the content of my.cnf

user		= mysql
pid-file	= /var/run/mysqld/
socket	= /var/run/mysqld/mysqld.sock
port		= 3306
basedir	= /usr
datadir	= /var/lib/mysql
tmpdir	= /tmp
lc-messages-dir	= /usr/share/mysql
bind-address    =

After adding it we did a /etc/init.d/mysql restart.

Grant User Access from Remote Host

When we check our database we only have one user and root has localhost access only, not wildcard or %:

MariaDB [(none)]> SELECT User, Host FROM mysql.user;
| User | Host      |
| root | localhost |
1 row in set (0.00 sec)

So we create a new user forge as Laravel Forge does it.

CREATE USER 'forge'@'localhost' IDENTIFIED BY 'tough password'
GRANT ALL PRIVILEGES ON * . * TO 'forge'@'localhost';

I also add ip address of web server to user forge

GRANT ALL PRIVILEGES ON * . * TO 'forge'@'';

Afterwards do not forget to do a


Database Creation

Do the following from the mysql command line

GRANT ALL PRIVILEGES ON forge TO 'forge'@'localhost'; 
GRANT ALL PRIVILEGES ON forge TO 'forge'@'';

IPTables / Firewall Update

To allow the web server access to the database server and other ip addresses not I did the following:

/sbin/iptables -A INPUT -p tcp --dport 3306 ! -s -j DROP

and to check it I did a :

echo -e "target prot opt source destination\n$(iptables -L INPUT -n | grep 3306)"

You will then see something like:

target prot opt source destination
DROP       tcp  -- !            tcp dpt:3306

NB For saving and loading of ip tables with ease use iptables-persistent

NBB See Great Cybercity article

Outstanding Issues

Still cannot connect via Sequel Pro and ssh. SSH works, but MySQL connection refused. So did a

GRANT ALL ON yourdatabase.* TO youruser@'*' IDENTIFIED BY 'yourpassword';

and flushed privileges once again. Also did a /etc/init.d/mysql restart. No joy. Time for a break.

Tried a:

mysql -u forge -h -P 3306 -p forge

from the web server, but got a

ERROR 2003 (HY000): Can't connect to MySQL server on '' (111 "Connection refused")

as well. So tried a (adjust ip address and user!):

GRANT ALL PRIVILEGES ON *.* TO 'root'@'192.168.100.%' IDENTIFIED BY 'my-new-password' WITH GRANT OPTION;

and flushed privileges. Did not help. So then I wondered if my my.cnf was being loaded..

Proper CNF Found

In the end thanks to SO and Spicyramen I did a stacktrace

strace mysql ";" 2>&1  | grep cnf
stat("/etc/my.cnf", 0x7ffcbe6d9ec0)     = -1 ENOENT (No such file or directory)
stat("/etc/mysql/my.cnf", {st_mode=S_IFREG|0644, st_size=869, ...}) = 0
open("/etc/mysql/my.cnf", O_RDONLY)     = 3
stat("/etc/mysql/conf.d/my.cnf", {st_mode=S_IFREG|0644, st_size=230, ...}) = 0
open("/etc/mysql/conf.d/my.cnf", O_RDONLY) = 4
stat("/etc/mysql/conf.d/mysql.cnf", {st_mode=S_IFREG|0644, st_size=8, ...}) = 0
open("/etc/mysql/conf.d/mysql.cnf", O_RDONLY) = 4
stat("/etc/mysql/conf.d/mysqldump.cnf", {st_mode=S_IFREG|0644, st_size=55, ...}) = 0
open("/etc/mysql/conf.d/mysqldump.cnf", O_RDONLY) = 4
stat("/etc/mysql/mariadb.conf.d/50-client.cnf", {st_mode=S_IFREG|0644, st_size=495, ...}) = 0
open("/etc/mysql/mariadb.conf.d/50-client.cnf", O_RDONLY) = 4
stat("/etc/mysql/mariadb.conf.d/50-mysql-clients.cnf", {st_mode=S_IFREG|0644, st_size=336, ...}) = 0
open("/etc/mysql/mariadb.conf.d/50-mysql-clients.cnf", O_RDONLY) = 4
stat("/etc/mysql/mariadb.conf.d/50-mysqld_safe.cnf", {st_mode=S_IFREG|0644, st_size=321, ...}) = 0
open("/etc/mysql/mariadb.conf.d/50-mysqld_safe.cnf", O_RDONLY) = 4
stat("/etc/mysql/mariadb.conf.d/50-server.cnf", {st_mode=S_IFREG|0644, st_size=3492, ...}) = 0
open("/etc/mysql/mariadb.conf.d/50-server.cnf", O_RDONLY) = 4
stat("/home/jasper/.my.cnf", 0x7ffcbe6d9ec0) = -1 ENOENT (No such file or directory)

and found out the /etc/mysql/mariadb.conf.d/50-server.cnf seemed to overrule my own my.cnf so changed the bind address to there and restarted the mysql server:

user@site-database:~$ sudo su
[sudo] password for user: 
root@site-database:/home/user# nano /etc/mysql/mariadb.conf.d/50-server.cnf
root@site-database:/home/user# /etc/init.d/mysql restart
[ ok ] Restarting mysql (via systemctl): mysql.service.

Then when I did another:

mysql -u forge -h -P 3306 -p forge

I got in.

Sequel Pro Access

With Sequel Pro I could not get in so did a:

/sbin/iptables -A OUTPUT -o lo -p tcp --sport 3306 -j ACCEPT

and then I did that rule added twice besides my unique ip address access only:

echo -e "target prot opt source destination\n$(iptables -L INPUT -n | grep 3306)"
target prot opt source destination
DROP        tcp  -- !            tcp dpt:3306
ACCEPT     tcp  --              tcp dpt:3306
ACCEPT     tcp  --              tcp dpt:3306

But it did not work and was not added to /etc/iptables/rules.v4 like the ip address rule.

So I had to remove persistent-tables:

sudo apt-get remove iptables-persistent

and the specific ip address rule for now doing a:

iptables -P INPUT ACCEPT

Followed by flushing the rules:

iptables -F INPUT
iptables -F OUTPUT
iptables -F FORWARD

Will restore the rules later. Just need to add some content first now. I do now have access from the web server and from Sequel Pro!


Jasper Frumau

Jasper has been working with web frameworks and applications such as Laravel, Magento and his favorite CMS WordPress including Roots Trellis and Sage for more than a decade. He helps customers with web design and online marketing. Services provided are web design, ecommerce, SEO, content marketing. When Jasper is not coding, marketing a website, reading about the web or dreaming the internet of things he plays with his son, travels or run a few blocks.

Leave a Reply

Your email address will not be published. Required fields are marked *

Back To Top