All writing

Writing

Slightly safer deployments for shared hosts

A Laravel deployment on shared hosting, a public_html full of things that shouldn't be there, and how to sort it out.

I was working on a client project and came across a few things in the deployment that needed attention. The entire Laravel application was sitting inside public_html. It worked, but it also put the whole project directory inside the domain's public web root.

And that's a pretty big security problem.

In this case, it was possible to download both database/database.sqlite and storage/logs/laravel.log over the internet. Files like .env.example, artisan, composer.json, and composer.lock were accessible too, and an info.php page exposed server information.

The structure looked roughly like this:

public_html/                  ← domain's public web root
├── app/
├── bootstrap/
├── config/
├── database/
│   └── database.sqlite        ← you could download this
├── resources/
├── routes/
├── storage/
│   └── logs/laravel.log       ← this too
├── vendor/
├── wp/                       ← WordPress installation
├── sideproject1/             ← another entire Laravel application
├── public/                   ← this should be Laravel's public web root
├── .env                      ← relied on an .htaccess rule for protection
├── .env.example
├── artisan
├── composer.json
├── composer.lock
└── info.php

You can go around blocking files one by one in .htaccess, but the main fix is to get those files out of the public area. Laravel already has a directory for handling browser requests: public/.

Preparing the application

The process is still a bit old-school, especially on shared hosting. Ideally, this whole thing would be automated through a pipeline, but until that exists, we can at least get the manual deployment in order.

First, build the assets locally or in CI. For this project, the command was:

npm ci
npm run production

This part depends on the project and its build tool. Use the script defined in your package.json.

We also need a clean vendor directory with just the production dependencies:

composer install --no-dev --optimize-autoloader

You can keep a local copy of the project just to prepare that vendor directory before uploading it. The environment used to install dependencies needs to be compatible with the hosting server's PHP version and extensions.

Check these settings in your production .env:

APP_ENV=production
APP_DEBUG=false

Separating the application from its public files

The idea is to keep the application in a directory outside public_html:

/home/user/
├── project/
│   ├── app/
│   ├── bootstrap/
│   ├── config/
│   ├── database/
│   ├── resources/
│   ├── routes/
│   ├── storage/
│   ├── vendor/
│   ├── .env
│   ├── artisan
│   ├── composer.json
│   └── public/
└── public_html -> /home/user/project/public

If your host lets you configure the domain's web root, you can point it directly at /home/user/project/public. Here, the solution was to point public_html at that directory with a symbolic link. This depends on the host allowing the link and the web server being able to follow it.

Before switching things around, check what's been left sitting in the project root. This project had quite a few things that still needed to be accessible through the browser:

robots.txt
sitemap.xml
cookie-policy.pdf
favicon.ico
css/
js/
images/

Those files go into public/. Meanwhile, .env, app/, config/, database/, storage/, vendor/, and the other internal files stay outside it.

That separation is the most important part of the change. If uploads need to be public, expose only the directory meant for those files, not the entire storage/ directory with logs and other internal data.

Uploading the files and switching the site's entry point

On this kind of hosting, deployment ends up being a mix of FTP/SFTP and SSH. In my case, the hosting provider's SSH access had to be enabled through the control panel before connecting:

ssh user@ftp.example.com

The application files now go into /home/user/project/.

For a new project, with the application in place and no existing public_html, create the link like this:

cd /home/user
ln -s /home/user/project/public public_html

The syntax is ln -s TARGET LINK_NAME. So public_html is the link's name, and the project's public/ directory is its destination.

This project was a little different: the old application was already sitting entirely inside public_html. So the existing directory became the project directory:

cd /home/user
mv public_html project
ln -s /home/user/project/public public_html

That mv isn't a required step in every Laravel deployment. It made sense for this migration, where project didn't exist yet. Before making this kind of switch in production, have a backup and account for the interruption between moving the directory and creating the link.

The resulting path was:

public_html → project/public

WordPress and sideproject1 in the middle of all this

The project also had a WordPress installation at /wp and another Laravel application at /sideproject1. Those URLs needed to keep working.

Two links were created inside public/:

cd /home/user/project
ln -s ../wp public/wp
ln -s ../sideproject1/public public/sideproject1

For sideproject1, the entire application stays in sideproject1/, but the domain only sees sideproject1/public/. The URL stays /sideproject1, without exposing app/, config/, .env, vendor/, and everything else alongside it.

WordPress is different: the public/wp link exposes the installation in wp/. It doesn't create the same separation that Laravel's public/ directory provides.

In this case, wp-config.php was also moved one level above the installation. I didn't know that was an option until I worked on this. WordPress still needs its own care around configuration, permissions, and accessible files, though.

The final structure looked like this:

/home/user/
├── project/
│   ├── app/
│   ├── config/
│   ├── database/
│   ├── storage/
│   ├── vendor/
│   ├── wp/
│   ├── wp-config.php
│   ├── sideproject1/
│   │   ├── app/
│   │   ├── .env
│   │   └── public/
│   ├── .env
│   ├── artisan
│   └── public/
│       ├── css/
│       ├── images/
│       ├── js/
│       ├── wp -> ../wp
│       ├── sideproject1 -> ../sideproject1/public
│       ├── robots.txt
│       ├── sitemap.xml
│       ├── cookie-policy.pdf
│       ├── index.php
│       └── .htaccess
└── public_html -> /home/user/project/public

What about .htaccess?

After the change, the .htaccess handling Laravel's entry point is the one at project/public/.htaccess.

Previously, the project root was public_html itself. Now Apache receives requests through public/, so that's where Laravel's entry point rules need to live.

Review the old rules during the switch, especially if they assumed the site was being served from the project root.

Testing what opens and what no longer does

After the change, I started with the URLs that needed to keep working:

/wp/
/sideproject1/
/admin/
/customer/login
/advertiser/login
/robots.txt
/sitemap.xml
/cookie-policy.pdf

That check also includes CSS, JavaScript, images, uploads, payments, and external callbacks. The homepage loading doesn't mean the whole deployment went well.

Then there's the other half of the test: trying to access things that shouldn't be available.

/.env
/.env.example
/artisan
/composer.json
/composer.lock
/database/database.sqlite
/storage/logs/laravel.log
/phpunit.xml
/info.php

You obviously shouldn't be able to download that pile of files anymore or open the diagnostic page. Check the response body too: the HTTP status alone doesn't tell you what the server actually returned.

Taking the files offline fixes the exposure going forward. It doesn't undo any downloads that may already have happened, so an exposed database and logs also need to be treated as an incident, including a review of the data and any potentially compromised credentials.

The deployment is still manual, but now the structure matches what Laravel expects: the browser reaches public/, and the internal files stay outside the public web root. That was the missing piece here.