Dev.to WebDev 🛠 Dev 👁 1 📖 7 min read

Deploying Laravel on Cheap cPanel Hosting Without Leaking Your .env

I'm Yudhi, a web developer from Indonesia, and a fair number of the Laravel apps I look after live on plain cPanel shared hosting. Not every client can pay for a VPS, and honestly, many small apps don't need one. What s

I'm Yudhi, a web developer from Indonesia, and a fair number of the Laravel apps I look after live on plain cPanel shared hosting. Not every client can pay for a VPS, and honestly, many small apps don't need one.

What shared hosting does need is care. Most "upload Laravel to cPanel" tutorials stop as soon as the homepage loads. That's the point where a lot of setups are quietly serving https://example.com/.env to anyone who asks for it.

This post covers the same deployment, but it's written around one rule: the only directory the web server should be able to serve is Laravel's public/ folder. Everything else follows from that.

1. Pick a folder layout (and know what each one costs you)

Layout A (recommended): app outside public_html

/home/USER/
├── app/              ← everything: app/, bootstrap/, config/, vendor/, .env, storage/
└── public_html/      ← ONLY the contents of app/public/

The .env, vendor/ and storage/ are physically outside the document root, so Apache has no URL that maps to them. This is the layout you want whenever the domain you're deploying is the account's main domain.

Layout B (fallback): app in a subfolder + .htaccess

Sometimes you can't move things around. Maybe it's an addon domain whose document root is fixed, or the host's File Manager makes it painful. Then you end up with:

/home/USER/public_html/
├── .htaccess         ← rewrites everything into laravel/public
└── laravel/          ← full Laravel project, INCLUDING .env

This works, but the whole project now sits inside the document root. If you get the rules wrong, /laravel/.env is a public URL. If you go with B, the .htaccess in section 2 isn't optional.

2. Wiring it up

Layout A: fix the paths in index.php

After moving the contents of app/public/ into public_html/, open public_html/index.php. In Laravel 11 it has three paths that point at ../, and all three need to point at ../app/:

<?php

use Illuminate\Http\Request;

define('LARAVEL_START', microtime(true));

if (file_exists($maintenance = __DIR__.'/../app/storage/framework/maintenance.php')) {
    require $maintenance;
}

require __DIR__.'/../app/vendor/autoload.php';

/** @var \Illuminate\Foundation\Application $app */
$app = require_once __DIR__.'/../app/bootstrap/app.php';

// Tell Laravel where the real public directory is now
$app->usePublicPath(__DIR__);

$app->handleRequest(Request::capture());

The file looks a little different in Laravel 10 (it boots the HTTP Kernel directly). The rule is the same, though: every __DIR__.'/../' becomes __DIR__.'/../app/'.

People often forget the maintenance path. If you skip it, php artisan down won't actually take the site down.

The usePublicPath() line matters too. Without it, public_path() still points at app/public, which will bite you in step 4.

Layout B: the root .htaccess

In public_html/.htaccess:

<FilesMatch "^\.env">
    Require all denied
</FilesMatch>

RewriteEngine On

# Block direct access to anything in the project except public/
RewriteRule ^laravel/(?!public/) - [F,L]

# Send every request into Laravel's public folder
RewriteCond %{REQUEST_URI} !^/laravel/public/
RewriteRule ^(.*)$ laravel/public/$1 [L]

Then there's a gotcha that's easy to miss: https://example.com/laravel/public/about is now a second working URL for /about. Search engines will happily index both. Apache uses the rewrite rules from the deepest .htaccess, so the redirect has to go at the top of laravel/public/.htaccess (Laravel's own file), right after RewriteEngine On:

# 301 any request that literally typed /laravel/public/... back to the clean URL
RewriteCond %{THE_REQUEST} \s/laravel/public/([^\s?]*) [NC]
RewriteRule ^ /%1 [R=301,L]

THE_REQUEST is the raw request line the browser sent. Your own internal rewrites never touch it, so this rule doesn't loop.

After deploying, test it from outside. curl -I https://example.com/laravel/.env should return 403, and curl -I https://example.com/laravel/public/ should return a 301.

3. .env, APP_KEY, and the cached-config trap

Create the .env on the server by hand. Don't upload your local one. The minimum safe production values:

APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com

DB_CONNECTION=mysql
DB_HOST=localhost
DB_DATABASE=cpuser_appdb
DB_USERNAME=cpuser_appuser
DB_PASSWORD=use-a-long-random-one

cPanel adds your account username as a prefix to database and user names, so copy them exactly as the MySQL Databases screen shows them.

APP_KEY without SSH: generate a fresh key on your machine and paste it in:

php artisan key:generate --show
# base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=

Don't copy your local dev key. Production should have its own.

The trap: if you zip your project after running php artisan config:cache locally, bootstrap/cache/config.php goes along with it. That file holds your local paths and credentials, and Laravel will use it instead of the server's .env. Before you zip, delete bootstrap/cache/config.php and bootstrap/cache/routes-*.php, or run php artisan optimize:clear.

Lock down the file itself. On most cPanel hosts PHP runs as your own user (suPHP, LSAPI, or PHP-FPM per account), so this is enough:

chmod 600 /home/USER/app/.env

4. storage:link when symlinks are disabled

php artisan storage:link creates public/storage → storage/app/public. On shared hosting it can fail in two ways: PHP's symlink() is in disable_functions, or the link gets created inside app/public instead of public_html.

Option 1: create the link with a shell command through cron. Even when PHP can't make symlinks, cron's shell usually can. Add a one-off cron job, wait a minute, then delete it:

ln -s /home/USER/app/storage/app/public /home/USER/public_html/storage

Option 2: skip the symlink entirely. Point the public disk straight at the web root in config/filesystems.php:

'public' => [
    'driver' => 'local',
    'root' => public_path('storage'), // works because of usePublicPath() above
    'url' => env('APP_URL').'/storage',
    'visibility' => 'public',
],

Uploads then land in public_html/storage directly. The catch is that these files now live outside app/, so remember them when you back up or redeploy.

5. Migrations without SSH

Plenty of cheap plans have no Terminal. You can borrow the cron trick again: add a temporary cron job that runs every minute, check the log, then remove it.

cd /home/USER/app && php artisan migrate --force >> /home/USER/migrate.log 2>&1

I like this better than the "temporary route that calls Artisan::call('migrate')" approach. A route is reachable from the internet, and it's easy to forget to delete. The other no-SSH option is to export the schema from local and import it in phpMyAdmin. That works, but your migrations table then has to match, or a later migrate will try to re-run everything.

6. The scheduler cron

In cPanel → Cron Jobs, add one job set to every minute (* * * * *):

cd /home/USER/app && php artisan schedule:run >> /dev/null 2>&1

If it doesn't fire, the culprit is usually php. The CLI binary cron uses can be a different version from the one your site runs on. Ask your host for the full path, which often looks like /usr/local/bin/php, /opt/cpanel/ea-php82/root/usr/bin/php (EasyApache), or /opt/alt/php82/usr/bin/php (CloudLinux). Use that path in place of php.

7. Permissions

storage/ and bootstrap/cache/ must be writable:

chmod -R 775 /home/USER/app/storage /home/USER/app/bootstrap/cache

Never use 777. It's a security hole, and on suPHP-style setups a world-writable directory or script often gets refused with a 500 error.

8. Common gotchas

  • 500 with nothing in the log: check the PHP version first. Laravel 10 needs PHP 8.1+ and Laravel 11 needs 8.2+. Set it per domain in MultiPHP Manager or Select PHP Version, and turn on the extensions Laravel needs (mbstring, pdo_mysql, openssl, fileinfo, ctype, tokenizer, xml).
  • Composer platform mismatch: if you build vendor/ locally on PHP 8.3 and the host runs 8.2, dependencies can break. Pin it in composer.json with "config": { "platform": { "php": "8.2.0" } }, then run composer install --no-dev --optimize-autoloader.
  • 404 on every route except /: the hidden .htaccess didn't get uploaded. File Manager hides dotfiles by default.
  • Need to see an error: read storage/logs/laravel.log before flipping APP_DEBUG=true on a live site. Debug pages can expose environment values.

I also wrote a more linear, step-by-step version of this deployment (with database creation screens and such) on my blog: How to Deploy Laravel to cPanel Shared Hosting.

Pre-launch checklist

  • [ ] curl -I https://example.com/.env → 403 or 404, never 200
  • /laravel/.env → 403, /laravel/public/ → 301
  • [ ] APP_ENV=production, APP_DEBUG=false, fresh APP_KEY
  • [ ] No local bootstrap/cache/config.php on the server
  • [ ] .env is 600; storage and bootstrap/cache writable, nothing at 777
  • [ ] Uploaded files load from /storage/...
  • [ ] Scheduler cron runs (check a log line or schedule:list)
  • [ ] Temporary migration cron/route removed

What's your go-to setup for Laravel on shared hosting? Do you use Layout A, a subfolder, or have you found a cleaner trick for hosts that block symlinks? I'd like to hear it in the comments.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.