` to only be shown as a preheader text and not inside the email itself:
```html
```
## Add an unsubscribe link
If your sending email more than once, make sure you add a link for the recipient to unsubscribe from your emails. Because nobody that wants to receive your email, should receive your email and it's better to not have them on your list anymore. Big numbers aren't everything and you probably get more reliably sending statistics and better open rates because of this.
### Improving email deliverability
Source: https://rocketeersapp.com/improving-email-deliverability
How to prevent emails going to spam and improve the overall deliverability of your email domain.
When you're sending email from your website or web app, nothing is more frustrating when you get notified by your customers that your emails aren't delivering or ending up in the junk folder.
This can feel like it is not fully in your power to improve this, but when you execute all of these steps in this article, you will get better email delivery and you've done everything you could do!
## Do not ever use your own mail server
Please do not fall in the trap of sending email using your own mail server or the SMTP server that comes with your regular mailbox. In the current digital age you can't fight the tech giants like Google, Microsoft and Apple. They will crush you when you try to send email using your own mailserver. By default they will not trust it and you need to send a lot of email continously to keep trust when you have obtained it (not likely).
## Use a trustworthy email service provider
Choose an email sending service that is reliable and has a good reputation. In the following order from best to still pretty good, use one of these options: [Postmark](https://postmarkapp.com), [SendGrid](https://sendgrid.com), [Amazon SES](https://aws.amazon.com/ses/) or [Mailgun](https://mailgun.com).
Added benefits to using one of these providers is that you get great insights in what happens with the email your sending. You can view all logs and events that happen before (hopefully) entering the receiving mailbox.
Click and open tracking is also an option, but this harms the privacy of for your users. Be careful to not enable this without thinking this through.
## Configure DKIM
To authenticate the sending server, you should configure DKIM correctly for your email service provider that you chose in the previous step. This way the receiving mail server can verify that the email indeed has been sent by the legitimate server you declared to use.
## Add a valid SPF record to your DNS
In case your email provider provides a SPF record, you should add it to the DNS of your email domain. The SPF record should contain a list of all allowed domains or IPs that are allowed to send email with your domain.
Postmark is known for not providing a SPF record, because they explain it's not required anymore because the Return-Path domain is now used to check for SPF alignment.
## Setup a DMARC policy
To prevent email spoofing further, the protocol DMARC (Domain-based Message Authentication, Reporting, and Conformance) has been invented. This works together with SPF and DKIM for authentication of emails and is used to describe the actions taken when an email is not aligned with SPF or DKIM. Because the protocol is relatively new (2012) it is not so widely used as it should be.
Because of this, DMARC is one of the best improvements you can make to your existing setup. Because it marks the quality of your overall setup and in comparison to other sending mail servers.
In its most basic form you could add a DMARC record without much work like this, and still improve your email reputation by only 'having' this record:
```
v=DMARC1; p=none; pct=100; sp=none; aspf=r;
```
This practically says: we are testing the use of DMARC (p=none), for all email (pct=100), do not reject emails from subdomains (sp=none) and align relaxed with SPF (aspf=r).
To make DMARC more strict (and useful), you should receive email reports to know if legit emails are not being blocked by your DMARC policy. Receiving and aggregating these reports can be a pain, so a service like [DMARC monitoring](https://dmarc.postmarkapp.com) could come in handy.
This is how a more strict record looks like, which will reject all email that does not align with your SPF and DKIM settings:
```
v=DMARC1; p=reject; pct=100; rua=mailto:abc@dmarc.postmarkapp.com; sp=reject; aspf=s; adkim=s;
```
## Prevent hard bounces
Nothing will hurt your email domain reputation more than sending email to large lists of that contain a large percentage of not working email addresses. These (hard) bounces get noticed by the email providers like Google and Microsoft, because a large percentage of users are using their email service of choice and when they see a spike of bounces form your domain, they will give you a negative score based on these events.
What you can do to prevent hard bounces:
- Use email confirmation for newsletters and user registrations
- Check email addresses before adding it to your list for common errors like typos, DNS errors and RFC spec validation
- Clean unknown email lists before sending ([NeverBounce](https://neverbounce.com))
## Write a decent subject line
If you don't want to send your emails straight to the junk folder of your users, be thoughtful with what you put in the subject line of your emails. If you use sketchy phrases like _free_, _buy_ or _now !!!_ than you will end up in the spam because this is what spammers also use to trigger the attention of people.
## Add an unsubscribe link
If your sending email more than once, make sure you add a link for the recipient to unsubscribe from your emails. Because nobody that wants to receive your email, should receive your email and it's better to not have them on your list anymore. Big numbers aren't everything and you probably get more reliably sending statistics and better open rates because of this.
## Test your email before sending it
There are some handy tools that can check a lot of requirements for a good delivery rate of your email. [Mail Tester](https://www.mail-tester.com) is one of these tools that can be a great help. Go to the website, copy the provided email address and send your email to this address. After a few seconds Mail Tester can show what errors are still in your email delivery. Make sure you get the maximum score before proceeding to sending it to your email list.
Get the underlying records right with our free [SPF](/spf), [DKIM](/dkim), and [DMARC](/dmarc) checkers, and confirm your server isn't on a [blacklist](/blacklist). For the writing and formatting side, see [best practices for sending email](/best-practices-sending-email).
## Errors
### CORS error: No Access-Control-Allow-Origin header
Source: https://rocketeersapp.com/cors-error-no-access-control-allow-origin
A CORS error means the browser blocked a cross-origin request because the server did not return the right headers. The fix is to send Access-Control-Allow-Origin from the API, not to disable browser security.
## About the error
In the browser console you see something like:
```bash
Access to fetch at 'https://api.example.com/users' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
```
CORS (Cross-Origin Resource Sharing) is a browser security mechanism. When your frontend on one origin calls an API on another origin, the browser only exposes the response if the API explicitly allows that origin via response headers. No header, blocked request.
## Why do I see this error
- The API doesn't send an `Access-Control-Allow-Origin` header.
- The frontend and API are on different origins (different domain, subdomain, port, or scheme).
- A **preflight** `OPTIONS` request (sent for non-simple requests) isn't being answered correctly.
- Credentials (cookies) are involved but the headers don't permit them.
Note the error is reported by the browser. The request often reaches your server fine, the browser just hides the response from your JavaScript.
## Solution
### Laravel
Laravel has built-in CORS handling. Configure the allowed origins in `config/cors.php`:
```php
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['https://app.example.com'],
'allowed_headers' => ['*'],
'supports_credentials' => true,
```
Set `supports_credentials` to `true` only if you send cookies, and in that case `allowed_origins` cannot be `*`, it must list explicit origins. Clear config after editing:
```bash
php artisan config:clear
```
### nginx
If you serve the API directly through nginx, add the headers in the relevant `location` block and answer the preflight `OPTIONS` request:
```nginx
location /api/ {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
if ($request_method = OPTIONS) {
return 204;
}
}
```
### Don't "fix" it in the browser
Disabling web security with a browser flag or a proxy extension only hides the error on your machine, every real visitor still gets blocked. CORS must be solved on the server that owns the API. For other browser-facing errors, see [500 Internal Server Error](/500-internal-server-error-laravel) and [ERR_TOO_MANY_REDIRECTS](/err-too-many-redirects).
### curl (60) SSL certificate problem: unable to get local issuer certificate
Source: https://rocketeersapp.com/curl-60-ssl-certificate-problem-unable-to-get-local-issuer-certificate
This curl error means it could not verify the remote server certificate against a trusted root. Usually the local CA bundle is outdated or missing, not a problem with the remote site.
## About the error
The message reads:
```bash
curl: (60) SSL certificate problem: unable to get local issuer certificate
```
curl connected over TLS but couldn't build a trust chain from the server's certificate up to a root certificate it knows. To verify a certificate, curl needs the issuing CA certificates available locally. If it can't find them, it errors out rather than trusting blindly.
## Why do I see this error
- The system's CA certificate bundle is outdated or missing.
- The server doesn't send its full chain, so an intermediate [certificate in the chain](/what-is-an-ssl-certificate-chain) is absent.
- The certificate (or an intermediate) has expired.
- The machine's clock is wrong, certificates are time-sensitive, so a bad system time breaks verification.
## Solution
### Update the CA bundle (the right fix)
On Debian or Ubuntu:
```bash
sudo apt update
sudo apt install --reinstall ca-certificates
sudo update-ca-certificates
```
On RHEL, CentOS or Fedora:
```bash
sudo yum reinstall ca-certificates
sudo update-ca-trust
```
This refreshes the trusted roots and resolves the error in the vast majority of cases.
### Point curl at a specific CA bundle
If the certificates are installed but curl still can't find them, tell it where to look:
```bash
curl --cacert /etc/ssl/certs/ca-certificates.crt https://example.com
```
For PHP's curl, set the path in `php.ini` so every request uses it:
```ini
curl.cainfo = "/etc/ssl/certs/ca-certificates.crt"
openssl.cafile = "/etc/ssl/certs/ca-certificates.crt"
```
### Diagnose with verbose output
To see exactly where the chain breaks:
```bash
curl -v https://example.com
```
### Do not disable verification
You'll see advice to use `curl -k` (or `CURLOPT_SSL_VERIFYPEER = false` in code). That turns off certificate verification entirely and exposes you to man-in-the-middle attacks. Fix the trust store instead. If you're chasing other curl trouble on older servers, see [error in the HTTP/2 framing layer](/error-in-the-http2-framing-layer).
### 502 Bad Gateway in nginx
Source: https://rocketeersapp.com/502-bad-gateway-nginx
A 502 Bad Gateway means nginx reached your application but got an invalid response back. In almost every case the backend (PHP-FPM) crashed, never started, or is unreachable.
## About error 502
A `502 Bad Gateway` is returned by nginx when it is acting as a reverse proxy and receives an invalid response from the upstream server it forwards requests to. For a PHP application that upstream is almost always **PHP-FPM**.
The important thing to understand: nginx itself is fine. The problem sits *behind* nginx, in the service it is trying to talk to.
## Why do I see this error
The most common causes, roughly in order:
- PHP-FPM is not running, crashed, or was never started.
- nginx points to the wrong PHP-FPM socket or port (a typo in `fastcgi_pass`).
- The socket file exists but the nginx user can't read it (wrong permissions).
- The backend died halfway through the request (often an out of memory kill).
Always start by reading the nginx error log, it names the exact reason:
```bash
tail -f /var/log/nginx/error.log
```
You'll see a line ending in something like `connect() to unix:/run/php/php8.3-fpm.sock failed (2: No such file or directory)` or `(111: Connection refused)`. That bracketed code tells you everything.
## Solution
First, check whether PHP-FPM is actually running and start it if not:
```bash
systemctl status php8.3-fpm
systemctl restart php8.3-fpm
```
Then confirm nginx is pointing at the socket that PHP-FPM actually listens on. The path in your nginx config must match the `listen` directive in the pool config (`/etc/php/8.3/fpm/pool.d/www.conf`):
```nginx
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
}
```
If the socket exists but you still get `(13: Permission denied)`, make sure the pool runs as the same user as nginx (usually `www-data`):
```ini
user = www-data
group = www-data
listen.owner = www-data
listen.group = www-data
```
Reload both services after any change:
```bash
systemctl reload php8.3-fpm
systemctl reload nginx
```
If the backend keeps dying mid-request rather than refusing the connection, the cause is usually resources, not configuration. Check the [504 Gateway Timeout](/504-gateway-timeout-nginx) article for slow responses, and [PHP allowed memory size exhausted](/php-allowed-memory-size-exhausted) for out of memory kills. When the backend is up but every worker is busy, you'll see a [503 Service Unavailable](/503-service-unavailable) instead. A backend returning oversized response headers triggers a different upstream error: [upstream sent too big header](/nginx-upstream-sent-too-big-header).
### 403 Forbidden in nginx
Source: https://rocketeersapp.com/403-forbidden-nginx
A 403 Forbidden means nginx found the resource but refuses to serve it. Almost always a file permission problem, a missing index file, or an explicit deny rule.
## About error 403
A `403 Forbidden` is returned when nginx successfully locates the request but is not allowed to serve it. Unlike a 404, the resource exists, nginx just won't hand it over.
## Why do I see this error
The usual causes, in rough order of likelihood:
- File or directory permissions the nginx worker user can't read or traverse.
- The URL points at a directory with no index file and `autoindex` off.
- An explicit `deny` rule in the configuration.
- Wrong ownership on the document root after a deploy.
As always, the nginx error log names the exact reason:
```bash
tail -f /var/log/nginx/error.log
```
You'll see lines like `directory index of "/var/www/html/" is forbidden` or `Permission denied`.
## Solution
### Fix permissions
nginx must be able to *read* files and *traverse* (execute bit) every directory in the path. Directories should be `755` and files `644`:
```bash
sudo find /var/www/html -type d -exec chmod 755 {} \;
sudo find /var/www/html -type f -exec chmod 644 {} \;
```
Ownership should match the web server user (`www-data` on Debian/Ubuntu, `nginx` on RHEL):
```bash
sudo chown -R www-data:www-data /var/www/html
```
For a Laravel app the web root is the `public` directory, and only `storage` and `bootstrap/cache` need to be writable, see [Laravel failed to open stream: Permission denied](/laravel-failed-to-open-stream-permission-denied).
### Missing index file
If the request is for a directory, make sure an index file is defined and present:
```nginx
index index.php index.html;
```
Avoid turning on `autoindex on;` on a public server, it exposes your file listing.
### Check for deny rules
Search your config for an explicit block you may have forgotten:
```nginx
location ~ /\.(?!well-known) {
deny all;
}
```
That example (blocking dotfiles) is correct, but an over-broad `deny` is a common self-inflicted 403. Reload after any change:
```bash
nginx -t && systemctl reload nginx
```
If the document root or PHP socket itself is misconfigured rather than just permissions, start from a clean [Nginx install](/how-to-install-nginx). A bad config can just as easily produce a [502 Bad Gateway](/502-bad-gateway-nginx) instead.
### SSL handshake failed in nginx (ERR_SSL_PROTOCOL_ERROR)
Source: https://rocketeersapp.com/ssl-handshake-failed-nginx
A failed TLS handshake means the browser and nginx could not agree on a secure connection. The top causes are an expired certificate and a missing intermediate chain.
## About the error
The visitor sees `ERR_SSL_PROTOCOL_ERROR` or "SSL handshake failed", and nginx logs an SSL error. The handshake is the negotiation that happens before any data is exchanged, if it fails, the page never loads over HTTPS.
## Why do I see this error
In production, almost always one of these:
- **Expired certificate**, the single most common cause.
- **Missing intermediate certificate**, nginx doesn't auto-fetch the chain, you must bundle it.
- **Wrong certificate or key path** in the config, or a key that doesn't match the certificate.
- **Outdated protocol**, the client requires TLS 1.2+ and the server only offers older versions.
## Diagnose
Test the live handshake with our free [SSL checker](/ssl), or with OpenSSL. The `-servername` flag sends SNI, which is required when one IP serves multiple certificates:
```bash
openssl s_client -connect example.com:443 -servername example.com
```
Check the certificate's expiry date directly:
```bash
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates
```
And read the nginx error log:
```bash
tail -f /var/log/nginx/error.log
```
## Solution
### Renew an expired certificate
With Certbot:
```bash
sudo certbot renew
sudo systemctl reload nginx
```
Automate renewal so it never expires again, Certbot installs a timer for this by default.
### Serve the full chain
The `ssl_certificate` directive must point at the **full chain** (your certificate followed by the intermediates), not just the leaf certificate:
```nginx
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
```
A leaf-only certificate works in some browsers and fails in others, a classic intermittent handshake failure.
### Enforce modern protocols
```nginx
ssl_protocols TLSv1.2 TLSv1.3;
```
Validate and reload after any change:
```bash
nginx -t && systemctl reload nginx
```
For a hardened SSL setup behind a CDN, see [an A+ grade SSL using Cloudflare](/a-plus-grade-ssl-using-cloudflare). If the chain problem shows up in command-line tools rather than browsers, see [curl (60) SSL certificate problem](/curl-60-ssl-certificate-problem-unable-to-get-local-issuer-certificate). When these same problems reach a visitor's browser, they see [your connection is not private](/your-connection-is-not-private) or [NET::ERR_CERT_AUTHORITY_INVALID](/net-err-cert-authority-invalid).
### Your connection is not private
Source: https://rocketeersapp.com/your-connection-is-not-private
This browser warning means the SSL certificate could not be validated. As a site owner it usually points at an expired certificate, a missing chain, or a domain mismatch you can fix on the server.
## About the error
Chrome shows a full-page "Your connection is not private" warning with a code such as `NET::ERR_CERT_*`. The browser couldn't validate the site's SSL certificate, so it blocks access to protect the visitor. If it's your own site, it means visitors are being turned away, so it's worth fixing fast.
## Why do I see this error
The specific code under the warning tells you which problem it is:
- **`NET::ERR_CERT_DATE_INVALID`** the certificate has expired (the most common).
- **`NET::ERR_CERT_AUTHORITY_INVALID`** untrusted issuer or missing chain, see [NET::ERR_CERT_AUTHORITY_INVALID](/net-err-cert-authority-invalid).
- **`NET::ERR_CERT_COMMON_NAME_INVALID`** the certificate doesn't cover the domain being visited (e.g. `www` missing).
- A wrong **system clock** on the visitor's device (the one cause that isn't your fault).
## Solution
### Renew an expired certificate
Expiry is the number one cause. With Certbot, renew and make sure auto-renewal is active so it never lapses again:
```bash
sudo certbot renew
sudo systemctl reload nginx
```
Check the expiry date directly, or use our free [SSL checker](/ssl) and the guide on [how to check SSL certificate expiration](/check-ssl-certificate-expiration):
```bash
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates
```
### Cover every domain the site answers on
A `COMMON_NAME_INVALID` error means the certificate is missing a name. Issue it for both the apex and `www` (and any subdomains you serve):
```bash
sudo certbot --nginx -d example.com -d www.example.com
```
### Serve the full chain
If the code is `AUTHORITY_INVALID`, point nginx at `fullchain.pem`, not the leaf-only `cert.pem`, so the browser can build the trust chain:
```nginx
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
```
Then validate and reload:
```bash
nginx -t && systemctl reload nginx
```
For the underlying TLS negotiation failures behind these warnings, see [SSL handshake failed in nginx](/ssl-handshake-failed-nginx), and for a hardened setup, [an A+ grade SSL using Cloudflare](/a-plus-grade-ssl-using-cloudflare).
### 504 Gateway Timeout in nginx
Source: https://rocketeersapp.com/504-gateway-timeout-nginx
A 504 Gateway Timeout means nginx reached your application, but the application took too long to respond. Unlike a 502, the backend is alive, it is just slow.
## About error 504
A `504 Gateway Timeout` is returned when nginx, acting as a reverse proxy, successfully connects to the upstream server but does not receive a complete response within the configured timeout window.
This is the key difference with a [502 Bad Gateway](/502-bad-gateway-nginx): with a 502 the backend gave a bad or no connection, with a 504 the connection was fine but the response never arrived in time.
## Why do I see this error
The backend is taking longer than nginx is willing to wait. Common reasons:
- A slow database query or a missing index.
- A long running job (report generation, export, image processing) handled inside the request instead of a queue.
- A slow external API call the request depends on.
- PHP's own `max_execution_time` is lower than the work needs.
By default nginx waits 60 seconds (`proxy_read_timeout` for proxied apps, `fastcgi_read_timeout` for PHP-FPM) before giving up.
## Solution
For a PHP-FPM application, raise the FastCGI read timeout in your `server` or `location` block:
```nginx
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_read_timeout 120s;
include fastcgi_params;
}
```
For a proxied application (Node, Octane, a separate service), raise the proxy timeouts instead:
```nginx
location / {
proxy_pass http://127.0.0.1:8000;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
```
PHP has its own limit too, so raise that as well or nginx will outwait a script that PHP already killed:
```ini
; php.ini
max_execution_time = 120
```
Reload after changing the configuration:
```bash
systemctl reload nginx
systemctl reload php8.3-fpm
```
Raising timeouts is a bandage, not a cure. The real fix is to make the request fast: add the missing database index, or move slow work (emails, exports, third-party calls) into a background queue so the request returns immediately. See [optimizing server performance](/optimize-server-performance) for more.
### 503 Service Unavailable
Source: https://rocketeersapp.com/503-service-unavailable
A 503 means the server is temporarily unable to handle the request. With Laravel it often means maintenance mode is on; on a busy server it means PHP-FPM has no free workers.
## About error 503
A `503 Service Unavailable` signals that the server is up but temporarily can't serve the request. It's meant to be transient. There are two very different situations that produce it, and the fix depends on which one you're in.
## Cause 1: Laravel maintenance mode
If you (or your deploy script) ran `php artisan down`, Laravel returns a 503 for every request on purpose. Bring it back up with:
```bash
php artisan up
```
If a deploy crashed midway and left the app down, the same command fixes it. You can also allow your own IP through while it's down:
```bash
php artisan down --secret="let-me-in"
```
Then visit `/let-me-in` once to bypass the maintenance page.
## Cause 2: PHP-FPM has no free workers
On a busy server, a 503 (often paired with `502`) means PHP-FPM hit its process limit and nginx had nowhere to send the request. Check the PHP-FPM log for the tell-tale warning:
```bash
tail -f /var/log/php8.3-fpm.log
```
You'll see `server reached pm.max_children setting, consider raising it`.
### Solution
Raise the worker pool in your pool config (`/etc/php/8.3/fpm/pool.d/www.conf`), sizing it to your available RAM:
```ini
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
```
Each PHP worker uses real memory (often 30–60 MB), so don't set `max_children` higher than `RAM / per-worker memory`. Reload after changing it:
```bash
systemctl reload php8.3-fpm
```
If workers are exhausted because requests are slow rather than because traffic is genuinely high, fix the slowness instead, see [504 Gateway Timeout](/504-gateway-timeout-nginx) and [optimizing server performance](/optimize-server-performance). If the 503 comes from a rate limit you configured, see [nginx rate limiting](/nginx-rate-limiting).
### NET::ERR_CERT_AUTHORITY_INVALID
Source: https://rocketeersapp.com/net-err-cert-authority-invalid
This browser error means the SSL certificate was not issued by a trusted authority, or the chain is incomplete. Usually a self-signed certificate, a missing intermediate, or an untrusted CA.
## About the error
Chrome shows `NET::ERR_CERT_AUTHORITY_INVALID` behind a "Your connection is not private" warning. The browser received a certificate it can't trace back to a Certificate Authority it trusts, so it refuses to proceed.
## Why do I see this error
- A **[self-signed certificate](/generate-self-signed-certificate-openssl)** (common in local dev and on staging).
- A **missing intermediate certificate**, the leaf is valid but the browser can't build the chain to a trusted root.
- A certificate from an **untrusted or unknown CA**.
- A certificate that doesn't match the domain, or has expired (often a slightly different error, but related).
## Solution
### Serve the full certificate chain
This is the most common production cause. nginx does not fetch intermediates for you, so `ssl_certificate` must point at the **full chain** (leaf + intermediates), not just your domain's certificate:
```nginx
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
```
Using `fullchain.pem` (not `cert.pem`) is what fixes the "authority invalid" error for an otherwise valid Let's Encrypt certificate. Reload after changing it:
```bash
nginx -t && systemctl reload nginx
```
### Verify the chain
Check what the server actually sends. A complete chain shows the intermediate; a broken one stops at your leaf:
```bash
openssl s_client -connect example.com:443 -servername example.com -showcerts
```
### Use a real certificate (not self-signed) in production
If this is a public site, issue a free, trusted certificate with Certbot instead of a self-signed one:
```bash
sudo certbot --nginx -d example.com -d www.example.com
```
### Local development
For a local self-signed certificate the warning is expected. Use a tool that installs a locally-trusted CA (such as Laravel Valet's TLS, or `mkcert`) rather than clicking through the warning every time.
### If only one device sees the error
When the site loads fine for everyone else, the problem is local to that machine, not the server:
- **Wrong date and time.** Certificate validation depends on the system clock. Correct the date, time and timezone, then reload the page.
- **Antivirus or firewall HTTPS scanning.** Security software that inspects encrypted traffic substitutes its own certificate. Turn off HTTPS/SSL scanning in the tool, or let it install its trusted CA.
- **A corporate proxy** performing TLS interception. Your network administrator needs to deploy the proxy's root certificate to the device's trust store.
This is the browser-facing cousin of two server-side TLS errors: [SSL handshake failed in nginx](/ssl-handshake-failed-nginx) and [curl (60) SSL certificate problem](/curl-60-ssl-certificate-problem-unable-to-get-local-issuer-certificate).
### ERR_TOO_MANY_REDIRECTS (redirect loop)
Source: https://rocketeersapp.com/err-too-many-redirects
This error means the browser was bounced between URLs until it gave up. The classic cause is a redirect loop between HTTP and HTTPS, very often a Cloudflare SSL setting fighting your server config.
## About the error
The browser shows `ERR_TOO_MANY_REDIRECTS` (Chrome) or "The page isn't redirecting properly" (Firefox). It means a URL redirected to another URL that redirected back, forming a loop. After a handful of hops the browser stops to avoid looping forever.
## Why do I see this error
The overwhelmingly common cause is an **HTTP ↔ HTTPS loop**, and the most common trigger of that is **Cloudflare's SSL mode set to "Flexible"**:
- Cloudflare talks to your server over plain HTTP.
- Your server (or app) redirects all HTTP to HTTPS.
- Cloudflare receives that redirect, requests again over HTTP, gets redirected again, forever.
Other causes: an app forcing HTTPS while a proxy already terminates TLS, a misconfigured `www` ↔ non-`www` redirect, or two redirect rules pointing at each other.
## Solution
### Fix Cloudflare SSL mode
If you use [Cloudflare](/what-is-cloudflare), set the SSL/TLS encryption mode to **Full** or **Full (strict)**, never **Flexible**. Flexible is the single biggest cause of this loop for sites behind Cloudflare. Full means Cloudflare connects to your origin over HTTPS, which matches your server redirecting to HTTPS. See [an A+ grade SSL using Cloudflare](/a-plus-grade-ssl-using-cloudflare).
### Trust the proxy's protocol header
When TLS is terminated by a proxy or load balancer, your app sees the request as plain HTTP and redirects to HTTPS, even though the visitor is already on HTTPS. Tell the app to trust the forwarded protocol. In Laravel, configure trusted proxies in `bootstrap/app.php`:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '*', headers:
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO
);
})
```
### Check your nginx redirect
A correct HTTP→HTTPS redirect redirects only plain HTTP, and the HTTPS server block must not redirect again:
```nginx
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
# serve the site here, do NOT redirect to https again
}
```
nginx can also loop internally, without the browser ever seeing a redirect, that's the [rewrite or internal redirection cycle](/nginx-rewrite-or-internal-redirection-cycle) error in the nginx error log.
### Diagnose the loop
Follow the redirect chain with [curl](/curl-command-complete-guide) to see exactly where it loops:
```bash
curl -sIL https://example.com | grep -i location
```
If you see the same two URLs alternating, you've found your loop.
### If it isn't your website
When you hit this on a site you don't control, the fix is on your end: delete that site's cookies and reload. A stale or corrupt session cookie is the usual culprit, and an incognito window (which starts with no cookies) is the fastest way to confirm it. If the page loads in incognito, clear the cookies in your normal window.
### 413 Request Entity Too Large in nginx
Source: https://rocketeersapp.com/413-request-entity-too-large
When uploading files you can encounter this error, which is caused by a limit in the nginx configuration.
## About error 413
The error with response code 413 shows up as "Request Entity Too Large" in the error logs of nginx and "Payload Too Large" in the developer console of your browser. Other ways of telling you about this same error could be "Content Too Large" or "Requested content-length of ... is larger than the configured limit of ...".
## Why do I see this error
This error happens when the uploaded file is larger than the configured maximum body size in nginx. Therefore the solution is to increase this limit.
## Solution
This is set in your nginx config; if you don't yet have one, see [how to install Nginx](/how-to-install-nginx). By increasing the `client_max_body_size` in nginx, we can make sure the uploaded files are accepted. This can be done in the `nginx.conf` file, or in the `sites-available` configuration file of your website:
```bash
client_max_body_size 100M;
```
You can set the limit using these units:
```bash
ms # milliseconds
s # seconds
m # minutes
h # hours
d # days
w # weeks
M # months, 30 days
y # years, 365 days
```
### Error in the HTTP2 framing layer
Source: https://rocketeersapp.com/error-in-the-http2-framing-layer
Once in a while I got this error when connecting to an external service on servers running Ubuntu 18.x or 20.x. After some research I got this flaky bug resolved!
## Root cause of the error
We have a lot of servers running on [Digitalocean](https://m.do.co/c/0801ad4bd810) that sends email using an email service provider like Postmark or Mailgun. And once in every 4-5 emails that were send, I got this error message stating the error "Error in the HTTP2 framing layer" following the URL of the API it was connecting to.
At first I thought the problem was on the API service I was using, because of a drop in the SSL connection or something.
But after some research online, it wasn't a third party problem. But a problem on the server itself, and specifically a bug in the version of [curl](/curl-command-complete-guide) available in the APT repositories of Ubuntu. It hit our servers on Ubuntu 18.x and 20.x, so [check your Ubuntu version](/check-ubuntu-version-command-line) to see if you're likely affected.
## Solution
The solution was to upgrade `curl` to the newest possible version. Because this new version is not offered through APT, we need to do it manually.
## How to upgrade curl manually
Here's to install the latest `curl` version (v8.2.1) at the time of writing this article.
```bash
# Remove installed curl version
apt remove curl -y
apt purge curl -y
# Install libs and compile tooling
apt-get update
apt-get install -y libssl-dev autoconf libtool makez
# Download new curl version, unzip, configure and compile build
cd /usr/local/src
wget https://curl.haxx.se/download/curl-8.2.1.zip
unzip curl-8.2.1.zip
cd /usr/local/src/curl-8.2.1
autoreconf -fi
./configure --with-ssl
make
make install
# Install curl globally
mv /usr/local/src/curl-8.2.1/src/.libs/curl /usr/local/bin/curl
sudo ldconfig
```
When done, make sure you restart the services using `curl` on the server so it will use this new version.
If curl complains about certificates instead of framing, that's a different problem: [curl (60) SSL certificate problem](/curl-60-ssl-certificate-problem-unable-to-get-local-issuer-certificate).
## Git
### GitHub Permission denied (publickey)
Source: https://rocketeersapp.com/github-permission-denied-publickey
When git clone, pull or push over SSH fails with this error, GitHub did not accept your SSH key. Usually the key is not generated, not added to your account, or not loaded into the agent.
## About the error
```bash
git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.
```
You're connecting to GitHub over SSH, and GitHub rejected the authentication because none of the keys your client offered matches a key on your account.
## Why do I see this error
- You haven't generated an SSH key yet.
- The public key isn't added to your GitHub account.
- The key exists but isn't loaded into the SSH agent.
- You're using an HTTPS-style setup but a remote that expects SSH (or vice versa).
## Solution
### Test the connection
GitHub has a dedicated endpoint for this. A success looks like a greeting, not a shell:
```bash
ssh -T git@github.com
```
If it says `Hi
!`, your key works and the problem is the repository remote, not auth. If it says `Permission denied`, continue below.
### Generate a key if you don't have one
```bash
ssh-keygen -t ed25519 -C "you@example.com"
```
Press enter to accept the default location (`~/.ssh/id_ed25519`).
### Add the public key to GitHub
Copy the **public** key (`.pub`) and paste it into GitHub under Settings → SSH and GPG keys → New SSH key:
```bash
cat ~/.ssh/id_ed25519.pub
```
### Load the key into the agent
```bash
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
```
### Wrong remote protocol
If your account is set up for SSH but the repo was cloned over HTTPS (or the reverse), switch the remote URL:
```bash
git remote set-url origin git@github.com:you/repo.git
```
For the general server-side version of this error (your own servers rather than GitHub), see [SSH Permission denied (publickey)](/ssh-permission-denied-publickey). New to SSH keys? See [what is an SSH key](/what-is-an-ssh-key) and [how to generate one](/generate-ssh-key).
### Git fatal: refusing to merge unrelated histories
Source: https://rocketeersapp.com/git-refusing-to-merge-unrelated-histories
This error means Git found no common ancestor between the two branches you are merging. It is a safety check, and there is a one-flag override once you are sure the merge is intentional.
## About the error
```bash
fatal: refusing to merge unrelated histories
```
Introduced in Git 2.9, this is a guard rail. When you merge or pull, Git looks for a shared commit the two histories descend from. If there isn't one, it refuses rather than stitch together two genuinely separate project histories by accident.
## Why do I see this error
- You created a repository locally (with its own commits) and then tried to pull from a remote that was also initialised separately, two roots, no shared ancestor.
- You're merging a branch that was started from scratch rather than branched off the others.
- The `.git` directory was deleted and recreated, so Git lost the shared history it used to know about.
## Solution
If you've confirmed the merge really is intended (you genuinely want to combine these two histories), pass the override flag:
```bash
git pull origin main --allow-unrelated-histories
```
Or, if you're merging a local branch:
```bash
git merge other-branch --allow-unrelated-histories
```
Git will create a merge commit joining the two roots, and from then on they share history, so you only need the flag once.
### Before you use the flag
The error is often a sign something is off, so it's worth a sanity check first:
- Are you pushing to the **right remote**? A typo'd remote URL pointing at an unrelated repo triggers this.
- Did you mean to **clone** instead of init + pull? If the remote already has the project, cloning it fresh avoids the whole situation.
```bash
git remote -v # confirm the remote points where you expect
```
If the remote is wrong, fix it rather than forcing the merge:
```bash
git remote set-url origin git@github.com:you/correct-repo.git
```
For the related "your push was rejected" situation, see [Git updates were rejected (non-fast-forward)](/git-updates-were-rejected-non-fast-forward). And if a botched merge leaves Git reporting it is [not a git repository](/git-not-a-git-repository), that guide covers the recovery.
### Git: Updates were rejected (non-fast-forward)
Source: https://rocketeersapp.com/git-updates-were-rejected-non-fast-forward
A rejected push means the remote branch has commits you do not have locally. Git refuses to overwrite them. The fix is to integrate the remote changes first, not to force-push.
## About the error
```bash
! [rejected] main -> main (non-fast-forward)
error: failed to push some refs to '...'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart.
```
A push can only "fast-forward" when your local branch is a direct continuation of the remote one. If someone else (or another machine of yours) pushed commits in the meantime, your branch has diverged, and Git rejects the push to avoid silently discarding their work.
## Why do I see this error
- A teammate pushed to the same branch before you.
- You pushed from another clone or worktree and forgot.
- The remote branch was amended or rebased, so the histories no longer line up.
## Solution
### Integrate the remote commits, then push
Fetch and merge the remote changes into your branch first:
```bash
git pull origin main
git push origin main
```
If you prefer a linear history without merge commits, rebase your work on top of theirs instead:
```bash
git pull --rebase origin main
git push origin main
```
Resolve any conflicts that come up, then push, this time it fast-forwards cleanly.
### Do not reach for --force
It's tempting to "fix" the rejection with `git push --force`, but on a shared branch that **deletes the commits the remote had that you didn't**, throwing away your teammate's work. Avoid it.
If you genuinely need to overwrite (for example, after intentionally rebasing your *own* feature branch), use the safer variant, which refuses if the remote moved in a way you haven't seen:
```bash
git push --force-with-lease
```
`--force-with-lease` aborts instead of clobbering if someone pushed since your last fetch, making it far safer than a blind `--force`. The related history error is covered in [Git fatal: refusing to merge unrelated histories](/git-refusing-to-merge-unrelated-histories).
### Git fatal: not a git repository
Source: https://rocketeersapp.com/git-not-a-git-repository
This error means you ran a git command somewhere git does not recognise as a repository. Usually you are in the wrong directory, or the repo was never initialised.
## About the error
```bash
fatal: not a git repository (or any of the parent directories): .git
```
Git commands operate on a repository, identified by a `.git` directory. When you run a git command, git looks in the current directory and walks up through the parents searching for `.git`. If it never finds one, you get this error.
## Why do I see this error
- You're in the **wrong directory**, outside the project, or one level above it.
- The directory was never initialised as a git repository.
- The `.git` directory was deleted (sometimes accidentally, sometimes by an over-broad clean).
- You cloned into a subdirectory and are running git from the parent.
## Solution
### Check where you are
First confirm your location and whether a `.git` directory exists here:
```bash
pwd
ls -la # look for a .git entry
```
If you don't see `.git`, you're either in the wrong place or this isn't a repo. `cd` into the actual project directory:
```bash
cd ~/Sites/my-project
git status
```
### Initialise a new repository
If this directory *should* be a repository but never was, create one:
```bash
git init
```
### Clone instead, if the project lives on a remote
If the code already exists on GitHub and you just don't have it locally, clone it rather than init:
```bash
git clone git@github.com:you/my-project.git
cd my-project
```
### Find the repository root from a subdirectory
If you're somewhere inside a repo but a parent got confused, this prints the top level (or errors if you're truly outside one):
```bash
git rev-parse --show-toplevel
```
If you recently saw [refusing to merge unrelated histories](/git-refusing-to-merge-unrelated-histories) and then this, a deleted or recreated `.git` directory is a likely common cause.
### Change casing of file or directory in Git
Source: https://rocketeersapp.com/change-casing-of-file-or-directory-in-git
Renaming already commited files or directories only for casing in Git will be ignored and will not show up as a change you can commit.
## The problem
Sometimes you encouter the problem that a file or directory does not have the correct case-sensitive name it should have. So you rename the file and change its casing. After this change, Git does not show this and the renaming can't be commited. It is the same family of problem as [removing tracked files that should have been ignored](/removing-tracked-files-git).
So this is not enough:
`mv File.php file.php`
## The reason
Since Git version 1.5.6 there is a setting `core.ignorecase` that defines if casing should be ignored by default or not. To change the default setting to pick up casing changes, you can run inside the project:
```
git config core.ignorecase false
```
Or to disable it completely on your computer, you can set it globally using:
```
git config --global core.ignorecase false
```
## The fix (without changing the config)
To fix this issue without changing any Git configurations: after you execute the `mv` command line from before, you also need to tell git that the filename has changed. This can be done by using:
```
git mv --cached File.php file.php
```
### Removing tracked files in Git that should have been ignored
Source: https://rocketeersapp.com/removing-tracked-files-git
Removing files that has been committed earlier in your repository is something you encouter once in a while. Read here how you can do this.
## The problem
Anybody that has been using Git for a longer period of time, stumbles every now and then on this pesky little problem: tracked files in your Git repository that should have been ignored from te start. But now they are added, Git can't seem to "forget" about them.
I know I have faced this one a bunch of times! And here's how you can fix this issue and make you feel all clean and happy about your repository again.
## Delete and forget unwanted committed files
In this example we have the common situation where you've forgot to add `.DS_Store` to your global or repositories `.gitignore` file on your Mac (for Windows users there is a different but evenly annoying `Thumbs.db` that gets created now and then).
```bash
find . -name .DS_Store -print0 | xargs -0 git rm -f --ignore-unmatch
```
What does this command do?
1. First it deletes every file that matches `.DS_Store`;
2. Then it pipes these files to the `git rm` command that makes sure Git doesn't keep tracking these files.
Without removing the traces of these files in Git, the `.DS_Store` files that were tracked will eventually pop up again in your working copy.
For a related Git tracking quirk — renames that only change letter case — see [changing the casing of a file or directory in Git](/change-casing-of-file-or-directory-in-git).
## Hosting
### Improving PHP performance
Source: https://rocketeersapp.com/php-performance
The performance and speed of PHP can be improved in different ways. Find out the multiple pathways to better performance.
## Enable OPcache
Using the OPcache PHP extension is one of the most significant (and easy) things you can do to improve PHP performance. When a PHP script runs, the server translates the script into a form the computer can understand (called "opcode") every time someone visits the website. This process takes time. With the OPcache extension enabled thee translated version (opcode) is stored in memory, so the server doesn’t have to re-translate the script every time. Instead, it reuses the stored version, making the website load faster.
```php
# Install OPcache extension (change PHP version accordingly)
sudo apt install php8.4-opache
# Enable it in php.ini
opcache.enable = 1
opcache.enable_cli = 1
opcache.max_accelerated_files = 50000
opcache.interned_strings_buffer = 64
opcache.memory_consumption = 256
opcache.save_comments = 1
opcache.validate_timestamps = 1
```
If you change `opcache.validate_timestamps` to `0` you can improve performance even more because OPcache does not need to check the file for modifications everytime. But then you will need to make sure you reload the PHP process every time you make changes in your code.
## Enable OPcache JIT compiler
After enabling OPcache, you should also consider enabling the [OPcache JIT compiler](/php-8-opcache-jit). It enhances performance by translating frequently executed PHP code into machine code at runtime, meaning running even more efficient on your server.
Enable JIT by adding these lines to the OPcache config:
```php
opcache.jit=on
opcache.jit_buffer_size=128M
```
### Website launch checklist
Source: https://rocketeersapp.com/website-checklist
A practical checklist of everything to verify before you put a website live — security, performance, backups, and the things that are painful to fix after launch.
Launching a website is the moment a dozen small details suddenly matter all at once. This checklist walks through what to verify before you flip the switch, grouped so you can work through it top to bottom. Most items link to a full guide if you need the how.
## Server and access
- [ ] Server provisioned with [Nginx](/how-to-install-nginx), [PHP](/how-to-install-php), and a [database](/how-to-install-mysql).
- [ ] SSH hardened — key-only login, root login disabled, and ideally a [non-default SSH port](/change-ssh-port-ubuntu).
- [ ] A firewall (`ufw`) allowing only the ports you actually use (80, 443, SSH).
- [ ] [Swap space configured](/add-swap-space-on-ubuntu) so a memory spike doesn't kill processes.
## Domain and HTTPS
- [ ] DNS records pointing at the server and propagated.
- [ ] A valid SSL certificate installed — via [Certbot](/how-to-install-certbot) or [Cloudflare](/what-is-cloudflare).
- [ ] [Automatic renewal](/renew-ssl-certificates-automatically) set up so the certificate never lapses.
- [ ] HTTP redirects to HTTPS, and HSTS is enabled — aim for an [A+ SSL grade](/a-plus-grade-ssl-using-cloudflare).
- [ ] No mixed content (no `http://` assets on an `https://` page).
## Security
- [ ] [Debug mode **off** in production](/features/turn-off-debug-mode-automatically-on-production) — a leaked stack trace exposes paths, config, and sometimes credentials.
- [ ] Security headers set (CSP, HSTS, `X-Content-Type-Options`) — verify with the [validation tools](/tools-to-validate-website).
- [ ] Database user scoped to its own database, never connecting as root.
- [ ] Secrets in [environment variables](/environment-variables-laravel), not in code or version control.
- [ ] Review [web application security](/optimize-web-application-security) for the full picture.
## Performance
- [ ] [Gzip or Brotli compression](/enable-gzip-compression-nginx) enabled.
- [ ] [OPcache enabled](/enable-opcache-php) for PHP.
- [ ] Static assets cached with sensible far-future headers.
- [ ] A reasonable [time to first byte](/measure-ttfb) — see [optimizing website performance](/optimize-website-performance).
## Reliability
- [ ] Automated [database backups](/backup-mysql-databases-single-file) running on a schedule — and test a restore.
- [ ] [File backups](/features/safely-backup-files-and-databases) for anything users upload.
- [ ] [Uptime and error monitoring](/features/monitor-your-sites-and-servers) so you hear about problems before your visitors do.
- [ ] Custom error pages for [404, 500](/500-internal-server-error-laravel), and [502](/502-bad-gateway-nginx) instead of raw server defaults.
## After launch
- [ ] Run the full suite of [website validation tools](/tools-to-validate-website).
- [ ] Confirm email sending works and isn't landing in spam — see [email deliverability](/improving-email-deliverability).
- [ ] Watch logs and metrics for the first day to catch anything the tests missed.
## Let Rocketeers handle it
Look back over that list — a large share of it is server configuration and ongoing operations: TLS and renewal, security headers, compression, backups, monitoring, and keeping debug mode off in production. Rocketeers handles those by default when it provisions a server and deploys a site, and keeps watching afterwards, so your launch checklist is mostly green before you even start ticking boxes.
### What is Cloudflare?
Source: https://rocketeersapp.com/what-is-cloudflare
Cloudflare sits between your visitors and your server, acting as a CDN, DNS host, and security layer all at once. Here is what it actually does and how it fits in front of your site.
[Cloudflare](/providers/cloudflare) is a service that sits in front of your website. Instead of visitors connecting straight to your server, their requests go to Cloudflare first, and Cloudflare decides what to serve from its own global network and what to forward to you. That one change — putting a smart layer between the internet and your origin server — is what makes everything else it offers possible.
## How it works: the proxy
When you move your DNS to Cloudflare and switch a record to "proxied," its public address becomes a Cloudflare IP, not yours. Every request now lands on the nearest Cloudflare data center, which either answers from cache or passes it back to your origin server. Your real server address is hidden behind Cloudflare.
That proxy position is the key idea. Because traffic flows through Cloudflare, it can cache content, filter attacks, and terminate HTTPS — all before a request ever reaches you.
## What it gives you
### DNS hosting
Cloudflare is one of the fastest DNS providers, and it's free. You manage your records — A, CNAME, MX, TXT — in its dashboard, and changes propagate quickly.
### A CDN (content delivery network)
Cloudflare caches your static files — images, CSS, JavaScript — in data centers around the world. A visitor in Tokyo is served from a nearby cache instead of waiting for a round trip to a server in Europe, which cuts load time and takes work off your origin.
### Free SSL/TLS
Cloudflare provides a trusted certificate for the connection between the visitor and Cloudflare at no cost, so your site is HTTPS without you issuing a certificate. (You still want a certificate on your origin for the Cloudflare-to-server hop — see [getting an A+ SSL grade with Cloudflare](/a-plus-grade-ssl-using-cloudflare).)
### Security: DDoS protection and a WAF
Because attacks hit Cloudflare's network first, it can absorb large denial-of-service floods and block malicious requests with its Web Application Firewall before they ever reach your server.
### Hiding your origin
With the proxy on, your server's real IP isn't public, which makes it much harder for an attacker to target your machine directly.
## One thing to watch: real visitor IPs
Once traffic is proxied, every request arrives from a Cloudflare IP, so your server logs would show Cloudflare instead of your actual visitors. The fix is to tell your web server to trust Cloudflare's IP ranges and read the real client address from the `CF-Connecting-IP` header. Cloudflare publishes its IP ranges, and they change over time, so this needs to stay current.
## Should you use it?
For most sites the free tier is a clear win on speed and security. The trade-offs and the cases where it really pays off are covered in [why do you need Cloudflare](/why-do-you-need-cloudflare).
## Let Rocketeers handle it
The fiddly part of Cloudflare isn't signing up — it's the wiring: importing your sites, getting the SSL mode right so you don't end up in a [redirect loop](/err-too-many-redirects), and keeping your web server's trusted-IP list in sync with Cloudflare's ranges so your logs and rate limits see real visitors. Rocketeers [imports your sites into Cloudflare](/features/import-multiple-sites-into-cloudflare) and keeps the IP ranges updated on your servers automatically, so the proxy works correctly without manual upkeep.
### Why do you need Cloudflare?
Source: https://rocketeersapp.com/why-do-you-need-cloudflare
You can run a website without Cloudflare — but here is what it gives you for free, when it genuinely matters, and the honest trade-offs.
Strictly speaking, you don't *need* Cloudflare. Plenty of sites run fine without it. But for a free service it delivers an unusual amount, and there are situations where it turns a hard problem into a non-issue. Here's an honest look at why you'd want it — and when you might not.
If you're new to what it actually is, start with [what is Cloudflare](/what-is-cloudflare).
## The case for using it
### It makes your site faster, globally
Cloudflare caches your static assets in data centers worldwide, so visitors far from your server don't pay the full round-trip cost. If your audience is spread across regions, this is the cheapest way to improve [time to first byte](/measure-ttfb) and overall load time without renting servers in every continent.
### It absorbs attacks for you
A denial-of-service flood hits Cloudflare's network, not your server. For a single-server setup, that's protection you simply cannot build yourself at any reasonable cost. The Web Application Firewall also blocks common exploit attempts before they reach your application.
### Free, trusted SSL
You get HTTPS at the edge without issuing or renewing a certificate. That alone removes a whole class of work — though for the best security you still pair it with a certificate on your origin and aim for an [A+ SSL grade](/a-plus-grade-ssl-using-cloudflare).
### It hides your server
With the proxy enabled, your origin IP isn't public. Attackers can't easily target a machine they can't find.
### Fast, free DNS
Even if you used nothing else, Cloudflare is among the quickest DNS hosts available — and managing records is straightforward.
## When you might not need it
- **Purely local or internal sites** that the public internet never reaches gain little from a global CDN.
- **Apps that can't tolerate a shared proxy** — some real-time or non-HTTP workloads need direct connections.
- **You already have a CDN and DDoS protection** from another provider and don't want a second layer.
It's also worth remembering that proxying adds a hop in front of your origin. Misconfigure the SSL mode and you get an [ERR_TOO_MANY_REDIRECTS](/err-too-many-redirects) loop; forget to trust its IP ranges and your logs show Cloudflare instead of real visitors. The benefits are large, but it isn't zero-configuration.
## The bottom line
For the vast majority of public websites, the free tier is close to a no-brainer: faster delivery, real attack protection, free SSL, and a hidden origin, all at no cost. The reason people hesitate isn't the value — it's the setup and upkeep.
## Let Rocketeers handle it
The value of Cloudflare is obvious; the friction is in wiring it up correctly and keeping it that way. Rocketeers [imports your domains into Cloudflare](/features/import-multiple-sites-into-cloudflare), sets sane SSL and DNS defaults, and keeps your servers' trusted-IP lists in sync with Cloudflare's ranges — so you get the speed and protection without the configuration that usually comes with it.
### How to host your own website
Source: https://rocketeersapp.com/how-to-host-your-own-website
Hosting a website yourself means turning a bare server into a secure, fast, production-ready machine. Here is the full picture — every step, in order, with a guide for each one.
"Just get a server and put your site on it" hides a surprising amount of work. A fresh Ubuntu box is a blank machine: no web server, no PHP, no database, no certificate, no firewall, no backups. Turning it into something you'd trust with real traffic is a sequence of steps, each with its own pitfalls. This is the whole map, in order, so you can see what hosting your own site actually involves — and follow a dedicated guide for each part.
## 1. Get and secure a server
Start with a virtual server from a cloud provider — DigitalOcean, Hetzner, Vultr, AWS, or similar — running a current Ubuntu LTS. Before anything else, lock it down:
- [Connect over SSH](/connect-to-server-ssh-command) using a key, and disable password login.
- Disable root login and consider [changing the SSH port](/change-ssh-port-ubuntu).
- Enable a firewall (`ufw`) that allows only SSH, HTTP, and HTTPS.
- [Add swap space](/add-swap-space-on-ubuntu) so a memory spike doesn't take the box down.
Skipping this step is how servers get compromised within hours of going online.
## 2. Install the web server
[Nginx](/how-to-install-nginx) is the front door — it answers every request, serves static files, and hands dynamic requests to your application. You'll set up a server block per site and learn the [try_files directive](/nginx-try-files) that routes requests correctly.
## 3. Install PHP
[Install PHP and PHP-FPM](/how-to-install-php) from the ondrej/php repository, with the extensions your application needs. If you host more than one site, you'll likely want [multiple PHP versions side by side](/how-to-install-multiple-php-versions-on-same-server). Then [tune php.ini](/important-php-config-options) for production — the defaults are not.
## 4. Install a database
[Install MySQL](/how-to-install-mysql) (or PostgreSQL), secure it, and create a dedicated database and user for your app — never let it connect as root. You'll want to know how to [import](/import-database-mysql-command-line) and [export](/export-database-mysql-command-line) data, too.
## 5. Install Node.js
Most modern sites compile their frontend assets, so [install Node.js](/how-to-install-nodejs) to run the build step during deployment.
## 6. Point your domain and add SSL
- Set your DNS records to point at the server — [Cloudflare](/what-is-cloudflare) is a fast, free option that adds a CDN and DDoS protection on top.
- Issue a trusted certificate with [Certbot](/how-to-install-certbot).
- Set up [automatic renewal](/renew-ssl-certificates-automatically) so it never expires.
- Tighten the configuration toward an [A+ SSL grade](/a-plus-grade-ssl-using-cloudflare).
Without a valid certificate, every visitor sees [your connection is not private](/your-connection-is-not-private).
## 7. Deploy your code
Now actually get your application onto the server. Doing it properly means [zero-downtime deployments](/zero-downtime-php-deployments): separate release folders, an atomic symlink swap, and the ability to roll back instantly — not overwriting live files in place.
## 8. Make it production-ready
The work that separates a hobby setup from a real one:
- **Performance** — [enable gzip/Brotli](/enable-gzip-compression-nginx) and [OPcache](/enable-opcache-php), and watch your [time to first byte](/measure-ttfb).
- **Security** — set security headers, keep debug mode off, and review [application security](/optimize-web-application-security).
- **Backups** — schedule [database backups](/backup-mysql-databases-single-file) and [test a restore](/features/safely-backup-files-and-databases). A backup you've never restored isn't a backup.
- **[Monitoring](/features/monitor-your-sites-and-servers)** — uptime and error alerts so you hear about problems first.
## 9. Verify before you launch
Run the [website validation tools](/tools-to-validate-website) and work through the [launch checklist](/website-checklist) so nothing slips through.
## So… should you do all this yourself?
You absolutely can — and doing it once is the best way to understand how a server actually works. But notice what you've signed up for: not just the install, but the upkeep. Security patches, certificate renewals, PHP upgrades, backups you have to verify, and monitoring you have to watch — forever, for every site and every server.
## Let Rocketeers handle it
Everything on this page — securing the server, installing and tuning Nginx, PHP, MySQL, and Node, issuing and renewing certificates, wiring up Cloudflare, deploying with zero downtime, backups, and monitoring — is exactly what Rocketeers does for you. You connect a server and a repository; it provisions the stack the production way and keeps it running. The point of understanding all these steps is knowing what you're getting back when you stop doing them by hand.
### No space left on device on Ubuntu
Source: https://rocketeersapp.com/no-space-left-on-device-ubuntu
This error means a filesystem is full, or out of inodes. Here is how to find what is eating the disk and reclaim it safely without breaking your server.
## About the error
You'll see it from almost any program that tries to write:
```bash
write error: No space left on device
```
It means the filesystem you're writing to is full. Occasionally the disk has free bytes but has run out of **inodes** (the structures that track files), which produces the same message.
## Find what's full
Start with a summary of disk usage per filesystem:
```bash
df -h
```
If a filesystem shows 100% but you can't see why, check inodes too:
```bash
df -i
```
Then find the biggest directories on the full filesystem:
```bash
sudo du -h --max-depth=1 / | sort -hr | head -20
```
Drill down into whichever directory is largest by repeating `du` one level deeper.
## Common culprits and fixes
### Systemd journal logs
The journal under `/var/log/journal` can quietly grow to gigabytes. Trim it:
```bash
sudo journalctl --vacuum-time=7d
# or cap by size
sudo journalctl --vacuum-size=200M
```
Cap it permanently in `/etc/systemd/journald.conf`:
```ini
SystemMaxUse=200M
```
### Application and web server logs
A runaway log file (nginx, Laravel, a stuck cron) is a classic cause. Truncate it in place rather than deleting it, so the writing process keeps its file handle:
```bash
sudo truncate -s 0 /var/log/nginx/error.log
```
Set up `logrotate` so it can't happen again.
### Package cache
On a server that's been updated a lot, the apt cache reclaims gigabytes:
```bash
sudo apt clean
sudo apt autoremove --purge
```
### Old kernels and Docker
Old kernels and unused Docker images/volumes are frequent space hogs:
```bash
sudo apt autoremove --purge # removes old kernels
docker system prune -a # if you run Docker
```
For more cleanup ideas see [reclaiming disk space on Ubuntu](/reclaim-diskspace-on-ubuntu). If the disk filled because of swap or you have none, see [adding swap space on Ubuntu](/add-swap-space-on-ubuntu).
### Address already in use (port already bound)
Source: https://rocketeersapp.com/address-already-in-use-port
This error means another process is already listening on the port you tried to bind. Find what is using it and either stop that process or pick a different port.
## About the error
Starting a server fails with one of these:
```bash
bind() to 0.0.0.0:80 failed (98: Address already in use) # nginx
Error: listen EADDRINUSE: address already in use :::3000 # Node
[ERROR] Can't start server: Bind on TCP/IP port: Address already in use # MySQL
```
Only one process can listen on a given port at a time. The bind fails because something already holds it.
## Why do I see this error
- The service is **already running** (you started it twice).
- A previous instance crashed but didn't release the port yet.
- A **different** program is using that port (Apache holding 80 when you start nginx, another dev server on 3000).
- A `php artisan serve` or Vite process from an earlier session is still alive.
## Solution
### Find what's using the port
`ss` (or `lsof`) shows the process holding the port. For port 80:
```bash
sudo ss -tlnp | grep ':80'
# or
sudo lsof -i :80
```
The output includes the PID and program name, exactly what's holding it.
### Stop it or kill it
If it's a service you control, stop it properly:
```bash
sudo systemctl stop apache2 # e.g. Apache squatting on port 80
```
If it's a stray process that won't go away, kill it by PID:
```bash
kill
# if it ignores that:
kill -9
```
To kill whatever is on a port in one step:
```bash
sudo fuser -k 80/tcp
```
### Or use a different port
If you actually want both running, change the port of the one you're starting. For `artisan serve`:
```bash
php artisan serve --port=8001
```
### Crashed process, port still held
A port can stay in `TIME_WAIT` briefly after a crash. Wait a few seconds and retry, or confirm with `ss` that nothing still owns it before restarting. For the focused walkthrough, see [how to kill the process on a port](/kill-process-on-port-linux).
### SSH Permission denied (publickey)
Source: https://rocketeersapp.com/ssh-permission-denied-publickey
This SSH error means the server rejected every key your client offered. Usually the right key is not being sent, or the file permissions on the server are wrong.
## About the error
Connecting over SSH fails with:
```bash
Permission denied (publickey).
```
The server only accepts public-key authentication, and none of the keys your client presented matched an entry in the account's `authorized_keys`. A related variant, `Too many authentication failures`, means your client offered so many wrong keys that the server cut you off before reaching the right one.
## Why do I see this error
- Your public key isn't in the server's `~/.ssh/authorized_keys`.
- The client is offering the wrong key, or every key in your agent.
- File permissions on the server's `.ssh` directory or `authorized_keys` are too open, so sshd ignores them.
- You're connecting as the wrong user.
## Solution
### Diagnose first
Verbose output shows exactly which keys are offered and how the server responds:
```bash
ssh -vvv user@your-server
```
### Make sure your key is on the server
The easiest way to install your public key is `ssh-copy-id`:
```bash
ssh-copy-id user@your-server
```
Or append it manually to `~/.ssh/authorized_keys` on the server.
### Offer only the right key
If you have many keys, the agent offers them all and can trip "Too many authentication failures". Force a single key and ignore the agent:
```bash
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519 user@your-server
```
Make it permanent in `~/.ssh/config`:
```
Host your-server
HostName your-server.example.com
User deployer
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
```
### Fix permissions on the server
sshd refuses keys if the files are world-writable. The `.ssh` directory must be `700` and `authorized_keys` must be `600`, both owned by the account's user:
```bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chown -R $USER:$USER ~/.ssh
```
After fixing this, reconnect and the key should be accepted.
### How many CPU cores on Ubuntu
Source: https://rocketeersapp.com/how-many-cpu-cores-on-ubuntu
This command for Ubuntu shows how many CPU cores are available on your machine.
Simple little command to get all the CPU cores active on your machine running on Ubuntu:
```bash
nproc --all
```
That's it!
To check available memory too, see [how much RAM memory is on your server](/how-much-memory-on-ubuntu), and [which processes use the most CPU](/top-processes-cpu).
### How much RAM memory on Ubuntu
Source: https://rocketeersapp.com/how-much-memory-on-ubuntu
This command for Ubuntu shows you the RAM memory on your server
The easiest way to see all installed, used, free and available RAM memory on your Ubuntu server is using the `free` command, we use the `-h` parameter for human readable sizes:
```bash
free -h
```
This outputs for example:
```
total used free shared buff/cache available
Mem: 7.8Gi 4.9Gi 609Mi 7.0Mi 2.3Gi 2.6Gi
Swap: 2.0Gi 253Mi 1.8Gi
```
If you only want one of these specific numbers:
```bash
# Total memory (ex. 7.8Gi)
free -h | awk '/^Mem:/ {print $2}'
# Used memory (ex. 4.9Gi)
free -h | awk '/^Mem:/ {print $3}'
# Free memory (ex. 609Mi)
free -h | awk '/^Mem:/ {print $4}'
# Shared memory (ex. 7.0Mi)
free -h | awk '/^Mem:/ {print $5}'
# Buffers/cache memory (ex. 2.3Gi)
free -h | awk '/^Mem:/ {print $6}'
# Available memory (ex. 2.6Gi)
free -h | awk '/^Mem:/ {print $7}'
```
To count CPU cores too, see [how many CPU cores your server has](/how-many-cpu-cores-on-ubuntu). When memory runs low, find [the processes using the most](/top-processes-memory) or [add swap space](/add-swap-space-on-ubuntu).
### Zero downtime deployments using PHP-FPM and nginx
Source: https://rocketeersapp.com/zero-downtime-php-deployments
Let me show you how you can deploy your PHP website or web applications without any downtime, using separate releases so you can rollback to a previous release quickly.
## Deploying with zero downtime
Of course you want to deploy your PHP applications without any interruptions for your visitors and users. But somehow, this isn't as simple as it reads. The combination of PHP (FPM), nginx and separate release folders make this task a little more complex that we would like. That's the bad news, the good news is we have a great way to solve these issues.
## Why separate releases are important
Why is it important to have separate releases? While deploying a new release, you still want to have the current release up and running. This can only be achieved by not overwriting the old release with the new release. Otherwise your active release will have downtime while updating and replacing the files from your fresh deploymnet.
This is also the only way we can make sure you can always instantly rollback to a previous deployment, when it's clear that there are problems with the just released version. By using a symlink, we can achieve multiple releases and point to one specific release.
## Common issues
The most common issue regarding zero downtime deployments is the moment of switching from the current to the next version, this could require reloading the nginx and PHP FPM processes and so you get challenged to keep your uptime.
So reloading these services should be avoided and that is the tricky part. But we've figured the ultimate way to omit reloading and to keep your application and active users safe!
## The deployment process
### Creating a release structure
First we need to create a top folder `/releases` where all releases can be found and a new release using a date format. I like to format `Y-m-d-HMS` which translates to `Year-Month-Day-HourMinuteSeconds` and for example `2024-10-08-144122`. This makes sure you can always distinguish a release folder from the time it was created and it orders them from old to new automatically.
Also we state that we have an active release folder, that's in `/releases/current`. Where `current` is a symlink (`ln -s`) to the folder with the currently active release.
```bash
NEW_RELEASE_DIRECTORY=$(date +"%Y-%m-%d-%H%M%S")
# Create `releases` folder
mkdir -p /releases
# Create folder for the new release
mkdir $NEW_RELEASE_DIRECTORY
```
We put the new release directory inside a variable because we need the same directory multiple times in the next steps, and we don't want to have a new timestamp every time we need it.
### Pulling in the code
Then we clone the code from the git repository. In this step it's important to not pull in anymore then we need for a new release. This means no additional history, because this will only take in extra space and makes the downloading of all data take longer and therefore slows down your deployment time.
```bash
git clone \
--depth 1 \
--branch main \
--single-branch \
git@github.com:rocketeers-app/rocketeers.git \
$NEW_RELEASE_DIRECTORY
```
### Install the application
This step contains al the commands you need to install your application. Using [Laravel](https://laravel.com) this will mean installing dependencies using Composer and possibly npm but also running migrations and clearing caches.
Here is an example Laravel deployment script for a Filament app:
```bash
# Install Composer dependencies
php composer install \
--no-ansi \
--no-dev \
--no-interaction \
--no-progress \
--optimize-autoloader \
--prefer-dist
# Run database migrations
php artisan migrate
# Terminate Horizon
php artisan horizon:terminate
# Clear caches
php artisan cache:clear
# Clear expired password reset tokens
php artisan auth:clear-resets
# Cache views
php artisan view:cache
# Cache routes
php artisan route:cache
# Link new storage folder
php artisan storage:link
# Optimize filament assets
php artisan filament:assets
# Cache Filament components and icons
php artisan filament:optimize
# Install npm dependencies
npm install
# Build assets using Vite
npx vite build
```
### Activate the new release
Now the critically and most significant step of the deployment process: activating the new release WITHOUT causing downtime!
First we enter the releases folder:
```bash
cd /releases
```
Then we create a new symlink called `deployment` inside:
```bash
# Create deployment symlink
ln -s ./releases/$NEW_RELEASE_DIRECTORY deployment
```
Now, to switch from the previous release (dynamically pointed by `releases/current`) to the new release folder, we overwrite the active `current` symlink with the newly created `deployment` symlink:
```bash
# Swap symlinks by overwriting the old with the new symlink
mv -Tf deployment current
```
And this makes all the difference, because this creates and overwrites a new symlink PHP-FPM and nginx will detect the change without the need of reloading!
### After deployment tasks
Sometimes it's needed to execute some optimization tasks, on a server with not a lot of diskspace, you could remove all `.git` folders to create some space:
```bash
# Remove .git folders from root and vendor files
rm -Rf ./.git
find vendor -type d -name '.git' -exec rm -rf {} +
```
### That's it!
Now we have successfully deployed a PHP application without reloading anything and with zero downtime. Rocketeers runs exactly this as [zero-downtime atomic deployments](/features/zero-downtime-atomic-deployments) for every site you host.
### How to check Ubuntu version
Source: https://rocketeersapp.com/ubuntu-version
Learn how to quickly get the Ubuntu version you're running using the command line.
If you want to know which Ubuntu version your server is running, simply execute the following command using the command line:
```bash
lsb_release -a
```
If you want to get a more specific output, so you only get one of the lines output by the command above. You can use the following parameters, where you'll see that every line has a logical letter corresponding to the label name:
```bash
lsb_release -i # Outputs: "Distributor ID: Ubuntu"
lsb_release -d # Outputs: "Description: Ubuntu 18.04.6 LTS"
lsb_release -r # Outputs: "Release: 18.04"
lsb_release -c # Outputs: "Codename: bionic"
```
If you only want the value and not the label of the output, add the `s` parameter to the command:
```bash
lsb_release -is # Outputs: "Ubuntu"
lsb_release -ds # Outputs: "Ubuntu 18.04.6 LTS"
lsb_release -rs # Outputs: "18.04"
lsb_release -cs # Outputs: "bionic"
```
For more ways to check, see [checking your Ubuntu version from the command line](/check-ubuntu-version-command-line), then [update Ubuntu](/update-ubuntu-command-line) to pull in security patches.
### Disable unnecessary and unused PHP versions (FPM pools)
Source: https://rocketeersapp.com/disable-unused-php-fpm-pools
Hosting your application optimally means you need to take care of the valuable server resources. PHP FPM can keep memory occupied even when not actively used.
When running PHP applications it's a common mistake to forget to turn off unused PHP versions. This mostly happens when you install a new PHP version and forget to uninstall or disable the previous PHP versions.
It is no problem to keep multiple PHP versions installed on your server. But it could be a problem to keep it running on your server. Likely this happens when using PHP FPM, which has a default www pool per PHP version that isn't turned off when it's not in use anymore.
So it could be running forerver and keeps precious server memory occupied.
## Stop unused PHP versions (FPM pools)
Stopping the processes for a PHP FPM pool is easy, using this command for your unneeded PHP version (e.g. 7.4):
```bash
sudo service php7.4-fpm stop
```
## Prevent PHP versions from starting on boot
To prevent the PHP version from starting when you reboot your server, disable the service all together:
```bash
sudo systemctl disable php7.4-fpm
```
> Using [Rocketeers](/) you won't have this problem. When changing PHP versions, Rocketeers will detect which sites and applications are running which versions and will disable all unused versions on your server.
These pools come from [installing multiple PHP versions](/how-to-install-multiple-php-versions-on-same-server); see [how to install PHP](/how-to-install-php) for the base setup.
### Reclaim disk space on Ubuntu server
Source: https://rocketeersapp.com/reclaim-diskspace-on-ubuntu
Your server disk space can clog up very quickly with a lot of unneeded files. Let's find out how to reclaim your precious disk space back.
There are a few ways that make it possible to reclaim diskspace right away on a server. This is very handy when your server is low on diskspace. Let's dive in!
## Shrink log files
Using this command, we can shrink logfiles within a specific size. In this example we shrink all system log files to have a maximum size of 500 MB:
```bash
sudo journalctl --vacuum-size=500M
```
**This article will be updated with more examples in the future**
If the disk is already full, start with [no space left on device](/no-space-left-on-device-ubuntu). On Docker hosts, [prune unused Docker data](/docker-prune) to claw back space.
### How to add Swap Space on Ubuntu servers
Source: https://rocketeersapp.com/add-swap-space-on-ubuntu
Servers with little resources can benefit greatly from adding some swap space for memory usage.3333
On servers with not a lot of [RAM memory](/how-much-memory-on-ubuntu) (< 1 GB) it's recommended to add some extra swap space in case a server needs a little bit more memory to keep everything going and to prevent it runs out of memory and can't continue processing requests.
## Choosing how much memory
The first step is to decide how much swap space you want to add to the server. I choose mostly 1 GB or 2GB. In this case, the notiation should be 1G (GB) or 1000M (MB).
```bash
sudo fallocate -l 1G /var/swap.1
```
## Enable swap space
Next step is to setup the swap file correctly and activate the swap space:
```bash
sudo chmod 600 /var/swap.1
sudo /sbin/mkswap /var/swap.1
sudo /sbin/swapon /var/swap.1
```
## Configure swappiness and cache pressure
To configure how fast the system will make use of the swap memory, we can tweak the values of the swappiness and the cache pressure.
The higher the swappiness, the faster it will reach for swap memory. Because we're running servers we would like to decrease the default swappiness (which is 60) of our Ubuntu servers.
Cache pressure defines how fast the system will take the memory back after it has been used. After some research and testing we found that these values work well on most server configurations:
```bash
sudo sysctl -w vm.swappiness=10
echo "vm.swappiness=10" | sudo tee -a /etc/sysctl.conf
sudo sysctl -w vm.vfs_cache_pressure=50
echo "vm.vfs_cache_pressure=50" | sudo tee -a /etc/sysctl.conf
```
### Make the changes permanent
We want to make these changes permanent and keep them also after a server reboots, so we need to execute these commands to make sure they will:
```
echo "/var/swap.1 none swap sw 0 0" | sudo tee -a /etc/fstab
sysctl -p
```
### How to get top processes with highest memory usage
Source: https://rocketeersapp.com/top-processes-memory
This command shows you which processes are eating all of your server memory.
Run this command to show the top 10 processes that are using the most memory (RAM) on your server:
```bash
ps -eo cmd,%mem --sort=-%mem | head -n 11
```
For CPU rather than memory, see [top processes by CPU](/top-processes-cpu). To check total available memory first, see [how much RAM your server has](/how-much-memory-on-ubuntu), and consider [adding swap space](/add-swap-space-on-ubuntu) if it's tight.
### How to get top processes with highest CPU usage
Source: https://rocketeersapp.com/top-processes-cpu
Want to know what's causing your server to slow down or what's keeping all these resources for itself. This command shows you which processes take it all.
Run this commando to show top 10 processes that are using the most CPU on your server:
```bash
ps -eo cmd,%cpu --sort=-%cpu | head -n 11
```
To find memory hogs instead, use [top processes by memory](/top-processes-memory); and when a process is holding a port, here's [how to kill it](/kill-process-on-port-linux).
## Laravel
### Setting process priority for Laravel Horizon workers
Source: https://rocketeersapp.com/laravel-horizon-nice-process-priority
Laravel Horizon lets you set the Linux nice value for worker processes, giving them more or less CPU scheduling priority.
## Why you'd want this
Linux schedules CPU time using a **nice value** between `-20` (highest priority) and `19` (lowest). By default processes start at `0`.
If your server is dedicated to queue processing or you're handling time-sensitive, CPU-intensive jobs, lowering the nice value gives workers a bigger slice of CPU time. On a shared server running web traffic alongside workers, you'd leave it at `0` or increase it to avoid starving your web processes.
## How to configure it
Add a `nice` key to the supervisor block in `config/horizon.php`:
```php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'processes' => 20,
'nice' => -5,
],
],
],
```
Horizon calls PHP's `proc_nice()` when it starts each worker.
## Permissions
Raising the nice value (e.g. `0 → 10`) works for any user. Lowering it (e.g. `0 → -5`) requires either root or the `CAP_SYS_NICE` capability. Without those privileges Horizon throws:
```
proc_nice(): Operation not permitted
```
To grant the capability to PHP without running as root:
```bash
sudo setcap cap_sys_nice=eip /usr/bin/php8.4
```
Verify it was applied:
```bash
getcap /usr/bin/php8.4
# /usr/bin/php8.4 cap_sys_nice=eip
```
Note: this grants the capability to the binary itself, so any PHP script running under that binary can adjust process priorities.
## Apply the changes
```bash
php artisan config:clear
php artisan horizon:terminate
```
If Horizon is managed by Supervisor:
```bash
sudo supervisorctl restart horizon:*
```
## Reverting
Remove the capability from the PHP binary:
```bash
sudo setcap -r /usr/bin/php8.4
```
No output from `getcap` afterwards means no capabilities are set. Also set `'nice' => 0` in `config/horizon.php` (or remove the key entirely) and restart Horizon.
For the broader picture of getting queues and the rest of the stack fast, see [Laravel performance](/laravel-performance). Horizon needs Redis — if workers can't connect, see [Redis connection refused](/redis-connection-refused-laravel).
### Redis connection refused in Laravel
Source: https://rocketeersapp.com/redis-connection-refused-laravel
When Laravel uses Redis for cache, sessions or queues, a "Connection refused" error means it cannot reach the Redis server. Usually the service is down or the host is wrong, especially in Docker.
## About the error
```bash
Predis\Connection\ConnectionException: Connection refused [tcp://127.0.0.1:6379]
```
(Or the phpredis equivalent.) Laravel tried to open a connection to Redis and nothing accepted it at that address. This is different from an authentication or wrong-database error, the connection never got established at all.
## Why do I see this error
- Redis isn't running.
- `REDIS_HOST` or `REDIS_PORT` is wrong.
- **In Docker**, `127.0.0.1` from inside the app container points at the container itself, not the Redis container.
- Redis requires a password (`requirepass`) that you haven't set in `.env`.
## Solution
### Check Redis is running
```bash
systemctl status redis-server
redis-cli ping # should reply PONG
```
If `ping` replies `PONG`, the server is up and the problem is how Laravel addresses it.
### Verify the connection settings
```ini
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
```
### Docker: use the service name
Inside a container, `127.0.0.1` is the container, not the host. Set `REDIS_HOST` to the **service name** from your `docker-compose.yml`:
```ini
REDIS_HOST=redis
```
```yaml
services:
redis:
image: redis:7
ports:
- "6379:6379"
```
### Clear cached config
If you changed `.env` but Laravel still connects to the old address, the config is cached:
```bash
php artisan config:clear
```
See [environment variables in Laravel](/environment-variables-laravel) for how these values are loaded, and [SQLSTATE[HY000] [2002] Connection refused](/sqlstate-hy000-2002-connection-refused) for the same class of "can't reach the service" problem with MySQL.
### Laravel failed to open stream: Permission denied
Source: https://rocketeersapp.com/laravel-failed-to-open-stream-permission-denied
A blank page or 500 error right after deploying is almost always a permission problem on the storage or bootstrap/cache directory. Here is how to fix the ownership properly.
## About the error
You'll find a line like this in your log, or on screen with debug enabled:
```bash
file_put_contents(/var/www/storage/logs/laravel.log): Failed to open stream: Permission denied
```
The path varies, it might be `storage/framework/views`, `storage/framework/cache`, or `bootstrap/cache`, but the cause is always the same: PHP tried to write a file and the operating system said no.
## Why do I see this error
Laravel needs to write to a few directories while it runs: logs, compiled Blade views, the framework cache and the config/route cache. If the user running PHP-FPM (usually `www-data`) does not own those directories, every write fails.
This bites most often right after a deploy or a `git clone`, because the files were created by your own user (or by root), not by the web server user. The daily log channel is a common trigger: whichever process writes the first log line of the day owns that file, and the other process then can't append to it.
## Solution
Give ownership of the writable directories to the web server user and set sensible permissions:
```bash
sudo chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache
sudo chmod -R 775 /var/www/storage /var/www/bootstrap/cache
```
Replace `www-data` with `nginx` on RHEL/CentOS based systems, and adjust the path to your project root.
If your deploy user and the web server user are different, add your deploy user to the web server group so both can write:
```bash
sudo usermod -aG www-data deployer
```
After fixing permissions, clear any half-written cache files:
```bash
php artisan optimize:clear
```
See [clearing the cache in Laravel](/clear-cache-laravel) for what that command does.
### A few warnings
- **Never use `chmod 777`.** It lets any user on the server write to your application files, which is a real security risk. `775` with the correct group ownership is enough.
- On **RHEL, CentOS or Fedora with SELinux** in enforcing mode, correct Unix permissions still aren't enough. You also need the right SELinux context:
```bash
sudo chcon -R -t httpd_sys_rw_content_t /var/www/storage /var/www/bootstrap/cache
```
With debug off, this error reaches visitors as a generic [500 Internal Server Error](/500-internal-server-error-laravel). The same kind of permission problem on the web root itself (rather than `storage`) instead produces a [403 Forbidden in nginx](/403-forbidden-nginx).
### No application encryption key has been specified
Source: https://rocketeersapp.com/no-application-encryption-key-has-been-specified
This Laravel error appears when the APP_KEY is missing. It is one of the first things you hit after cloning a project. The fix is a single artisan command.
## About the error
The full message is:
```bash
RuntimeException: No application encryption key has been specified.
```
Laravel uses the `APP_KEY` to encrypt cookies, sessions and anything you pass through the `Crypt` facade. Without it, the framework refuses to boot rather than fall back to no encryption.
## Why do I see this error
The `APP_KEY` value in your `.env` is empty or missing. This almost always happens right after:
- Cloning a project from Git, the `.env` is gitignored, so you start without a key.
- Copying `.env.example` to `.env` without generating a fresh key.
## Solution
Generate a key. Laravel writes it straight into your `.env` for you:
```bash
php artisan key:generate
```
If you don't have a `.env` file yet, create one first:
```bash
cp .env.example .env
php artisan key:generate
```
After generating, your `.env` will contain a value like:
```ini
APP_KEY=base64:Rk9wq3y...=
```
With debug off, a missing key reaches visitors as a generic [500 Internal Server Error](/500-internal-server-error-laravel), so this is worth checking first on a fresh deploy.
If you cached your config, clear it so the new key is picked up:
```bash
php artisan config:clear
```
### On a server
If you see this in production, the deploy likely doesn't have an `APP_KEY` set. Generate one **once** and keep it stable, changing it later invalidates every existing session and encrypted cookie, logging all your users out and making old encrypted data unreadable.
For a non-interactive deploy you can write the key without the confirmation prompt:
```bash
php artisan key:generate --force
```
See [environment variables in Laravel](/environment-variables-laravel) for more on how `.env` values are loaded.
### MySQL 1071: Specified key was too long (Laravel migration)
Source: https://rocketeersapp.com/mysql-1071-specified-key-was-too-long
This migration error happens on older MySQL with the utf8mb4 charset, where indexed string columns exceed the index size limit. The fix is a one-line default in your service provider.
## About the error
Running `php artisan migrate` on a fresh install against older MySQL throws:
```bash
SQLSTATE[42000]: Syntax error or access violation: 1071
Specified key was too long; max key length is 767 bytes
```
It almost always fails on the `users` table when creating a unique index on the email or another `VARCHAR(255)` column.
## Why do I see this error
Laravel defaults to the `utf8mb4` charset (proper 4-byte Unicode, needed for emoji and many scripts). In `utf8mb4` each character can take up to 4 bytes, so a `VARCHAR(255)` index needs `255 × 4 = 1020` bytes.
On **MySQL before 5.7.7** (and MariaDB before 10.2), the per-index limit without large-prefix support is **767 bytes**, less than 1020, so the index creation fails.
## Solution
### Recommended: set a default string length
Laravel ships with a one-line fix. In `app/Providers/AppServiceProvider.php`, cap the default string length so indexed `VARCHAR` columns fit:
```php
use Illuminate\Support\Facades\Schema;
public function boot(): void
{
Schema::defaultStringLength(191);
}
```
`191 × 4 = 764` bytes, just under the 767 limit. Then re-run the migration:
```bash
php artisan migrate:fresh
```
### Better long-term: upgrade MySQL
The 767-byte limit is a limitation of old versions. MySQL 5.7.7+ and 8.0 raise it to 3072 bytes with the InnoDB large prefix, and the error disappears without capping your column lengths. See [upgrading MySQL 5.7 to 8.0 on Ubuntu](/upgrade-mysql-5-7-to-8-0-ubuntu).
### Per-column alternative
If you only need it on a specific migration, set the length on the column directly instead of globally:
```php
$table->string('email', 191)->unique();
```
### Target class does not exist in Laravel
Source: https://rocketeersapp.com/target-class-does-not-exist-laravel
This error means Laravel cannot resolve a controller or class you referenced in a route or container binding. Almost always a namespace, typo or autoload issue.
## About the error
The message looks like:
```bash
Illuminate\Contracts\Container\BindingResolutionException:
Target class [App\Http\Controllers\UserController] does not exist.
```
Laravel's service container tried to instantiate a class by its name and couldn't find it.
## Why do I see this error
There are a few usual suspects:
- A typo in the controller name in your route definition.
- A missing or wrong `namespace` at the top of the controller file.
- You're using the **string** controller syntax on Laravel 8+, where the default namespace prefix was removed.
- The autoloader hasn't picked up a newly created class.
## Solution
### Use the class-based route syntax
Since Laravel 8 the recommended way to reference a controller is by importing it and using its `::class` constant, not a string:
```php
use App\Http\Controllers\UserController;
Route::get('/users', [UserController::class, 'index']);
```
This catches typos at compile time and resolves the namespace for you. The old string form relied on a namespace prefix that no longer exists by default:
```php
// Fragile, the App\Http\Controllers prefix is no longer applied automatically
Route::get('/users', 'UserController@index');
```
### Check the namespace
Open the controller and confirm its namespace matches its folder:
```php
namespace App\Http\Controllers;
class UserController extends Controller
{
// ...
}
```
A file in `app/Http/Controllers/Admin/` must declare `namespace App\Http\Controllers\Admin;`.
### Refresh the autoloader
If the class genuinely exists and the namespace is right, regenerate Composer's autoload map and clear caches:
```bash
composer dump-autoload
php artisan optimize:clear
```
See [clearing the cache in Laravel](/clear-cache-laravel) for what gets cleared. A similar "it built but Laravel can't find it" error is [Vite manifest not found](/vite-manifest-not-found-laravel).
### Vite manifest not found in Laravel
Source: https://rocketeersapp.com/vite-manifest-not-found-laravel
This error means Laravel cannot find your compiled frontend assets. Either the dev server is not running, or you never built your assets for production.
## About the error
The message reads:
```bash
Illuminate\Foundation\ViteException: Vite manifest not found at: /var/www/public/build/manifest.json
```
The `@vite` directive in your Blade layout looks up that `manifest.json` to know which compiled CSS and JS files to load. If the file isn't there, Laravel throws.
## Why do I see this error
The manifest is generated by Vite when it builds your assets. It's missing because:
- You're in development but the Vite dev server isn't running.
- You're in production but you never ran the build step.
- The `public/build` directory wasn't deployed to the server.
## Solution
### In development
Start the Vite dev server and leave it running alongside your application:
```bash
npm run dev
```
While `npm run dev` is running, `@vite` talks to the dev server directly and no manifest file is needed.
### In production
Build the assets. This is what generates `public/build/manifest.json`:
```bash
npm install
npm run build
```
Make this part of your deploy process so it runs on every release. If you build locally and deploy, make sure the `public/build` directory is actually uploaded and **not** excluded by a `.gitignore` or rsync filter.
### Older projects (Laravel Mix)
If your project still uses Laravel Mix instead of Vite, the equivalent error mentions `mix-manifest.json`. The fix is the same idea with Mix's commands:
```bash
npm run dev # development
npm run production # build for production
```
For zero-downtime deploys where assets are built per release, see [zero downtime PHP deployments](/zero-downtime-php-deployments).
### 500 Internal Server Error in Laravel
Source: https://rocketeersapp.com/500-internal-server-error-laravel
A 500 error is a generic "something broke" on the server. The error itself tells you nothing, the real message is in your logs. Here is where to look and the usual culprits.
## About error 500
A `500 Internal Server Error` means the server hit an unhandled error while processing the request. It's deliberately generic: in production Laravel hides the details so it doesn't leak sensitive information to visitors. The actual cause is always written somewhere you can read.
## Where to look first
Don't guess, read the log. For Laravel:
```bash
tail -n 100 storage/logs/laravel.log
```
If nothing's there, the error happened before Laravel booted, so check the web server and PHP-FPM logs:
```bash
tail -n 100 /var/log/nginx/error.log
tail -n 100 /var/log/php8.3-fpm.log
```
See [logging in Laravel](/laravel-logging) for configuring where these go.
## Common causes
- **Missing `APP_KEY`**, see [No application encryption key has been specified](/no-application-encryption-key-has-been-specified).
- **File permissions** on `storage` or `bootstrap/cache`, see [failed to open stream: Permission denied](/laravel-failed-to-open-stream-permission-denied).
- **A stale cached config** referencing values that no longer exist.
- **A missing `.env`** or wrong database credentials.
- **A PHP error**, out of memory, or a fatal syntax error in deployed code.
## Solution
### Temporarily see the real error
On a staging or local environment, enable debug mode to render the actual exception instead of the generic page:
```ini
APP_DEBUG=true
```
**Never leave `APP_DEBUG=true` on in production**, it exposes stack traces, environment values and database details to the public. Turn it back off the moment you've found the cause.
### Clear stale caches
A frequent cause right after deploy is a cached config or view pointing at something stale:
```bash
php artisan optimize:clear
```
This clears the config, route, view and event caches in one go, see [clearing the cache in Laravel](/clear-cache-laravel).
### Re-cache for production
Once it works, rebuild the caches for performance:
```bash
php artisan config:cache
php artisan route:cache
```
### CSRF token mismatch in Laravel
Source: https://rocketeersapp.com/csrf-token-mismatch-laravel
A CSRF token mismatch means Laravel rejected a request because its token was missing, wrong, or expired. It is the same protection behind the 419 page, here is how to send the token correctly.
## About the error
You'll see `CSRF token mismatch.` in an exception or API response, or the user lands on a [419 Page Expired](/419-page-expired-laravel) page. Laravel verifies a token on every state-changing request (`POST`, `PUT`, `PATCH`, `DELETE`) to block Cross-Site Request Forgery, one of several [web application security](/optimize-web-application-security) protections. If the token doesn't match the one in the session, the request is rejected. This is unrelated to a [CORS error](/cors-error-no-access-control-allow-origin), a separate browser-enforced cross-origin check that's easy to confuse with it.
## Why do I see this error
- A form was submitted **without the `@csrf` token**.
- An **AJAX request** didn't send the `X-CSRF-TOKEN` header.
- The session **expired** (the page sat open too long), so the token is stale.
- A **session/cookie problem**: wrong `SESSION_DOMAIN`, or cookies blocked behind a proxy.
## Solution
### Forms
Add the `@csrf` Blade directive inside every form. It outputs the hidden `_token` field Laravel checks:
```blade
```
### AJAX requests
Expose the token in a meta tag and send it as a header on every request. With Axios:
```html
```
```javascript
window.axios.defaults.headers.common['X-CSRF-TOKEN'] =
document.querySelector('meta[name="csrf-token"]').content;
```
With jQuery:
```javascript
$.ajaxSetup({
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }
});
```
### Expired sessions
If users hit this after leaving a tab open, the token expired with the session. You can't avoid expiry entirely, but you can detect a 419 in your AJAX layer and refresh the page or token gracefully rather than failing silently.
### Stateless routes (APIs, webhooks)
CSRF protection is for session-based, browser-driven requests. For a stateless API or an incoming webhook it doesn't apply, exclude those routes and authenticate with tokens or signed URLs instead. See [disabling CSRF in Laravel](/disable-csrf-in-laravel) for how to exclude specific routes.
### Environment variables in Laravel
Source: https://rocketeersapp.com/environment-variables-laravel
A complete list of all available environment variables used in the latest Laravel (version 11).
## What is an environment variable
Laravel has a `config` folder that contains all configuration files in one place, to change a configuration you could change the value in the code, or change it inside the `.env` when it has an environment variable as the default value (with a fallback).
Using the `.env` file has the advantage that every environment (typically local, staging or production) can have its own configuration based on the (yes) environment it runs in.
The advantage of using the config files without the `env()` helper has the advantage that other developers on the same project can't forget to set important configurations to certain required values.
## Typically used for secrets
But beware, everything that's a secret, should only be present in the `.env` file and never in your code or version control (like git).
## Don't use `env()` outside the config folder
It's considered a bad practice to use the `env()` helper outside your `config` folder. Always use the `config()` helper instead. Why? Because Laravel has a feature called config caching (using `artisan config:cache`) and when the cache is created, Laravel will not read the `.env` file anymore because it solely relies on the cached configuration inside your `config` folder.
## Keep your `APP_KEY` safe
When using encryption, Laravel relies on the `APP_KEY` environment variable. This variable can be conveniently generated using `artisan key:generate`.
But be careful, already encrypted values rely on the value of this key. When the key is changed, the current encrypted values cannot be decrypted anymore. If you need to change the `APP_KEY`, make sure you'll make use of [gracefully rotating the encryption keys](https://laravel.com/docs/11.x/encryption#gracefully-rotating-encryption-keys).
Save the `APP_KEY` somewhere safe, when it's gone, your encrypted values in for example your database are also gone.
> A common misconception is that the passwords in your user table are also relying on the `APP_KEY`. This is not the case!
>
> Passwords are hashed values and are never meant to be un-hashed. Validating a password relies on the hashing algorithm (for Laravel bcrypt is the default driver) and the `APP_KEY` is only used for encrypting and decrypting.
To change your Laravel app's configuration per environment, you can use the `.env` file (derived from `.env.example`) included in every Laravel project. But this does not list all possible `env` variables. So here's a complete list with all default environment variables.
## List of all environment variables
```bash
APP_DEBUG
APP_ENV
APP_FAKER_LOCALE
APP_FALLBACK_LOCALE
APP_KEY
APP_LOCALE
APP_MAINTENANCE_DRIVER
APP_MAINTENANCE_STORE
APP_NAME
APP_PREVIOUS_KEYS
APP_TIMEZONE
APP_URL
AUTH_GUARD
AUTH_MODEL
AUTH_PASSWORD_BROKER
AUTH_PASSWORD_RESET_TOKEN_TABLE
AUTH_PASSWORD_TIMEOUT
AWS_ACCESS_KEY_ID
AWS_BUCKET
AWS_DEFAULT_REGION
AWS_ENDPOINT
AWS_SECRET_ACCESS_KEY
AWS_URL
AWS_USE_PATH_STYLE_ENDPOINT
BEANSTALKD_QUEUE
BEANSTALKD_QUEUE_HOST
BEANSTALKD_QUEUE_RETRY_AFTER
CACHE_STORE
DB_CACHE_CONNECTION
DB_CACHE_LOCK_CONNECTION
DB_CACHE_TABLE
DB_CHARSET
DB_COLLATION
DB_CONNECTION
DB_DATABASE
DB_ENCRYPT
DB_FOREIGN_KEYS
DB_HOST
DB_PASSWORD
DB_PORT
DB_QUEUE
DB_QUEUE_CONNECTION
DB_QUEUE_RETRY_AFTER
DB_QUEUE_TABLE
DB_SOCKET
DB_TRUST_SERVER_CERTIFICATE
DB_URL
DB_USERNAME
DYNAMODB_CACHE_TABLE
DYNAMODB_ENDPOINT
FILESYSTEM_DISK
LOG_CHANNEL
LOG_DAILY_DAYS
LOG_DEPRECATIONS_CHANNEL
LOG_DEPRECATIONS_TRACE
LOG_LEVEL
LOG_PAPERTRAIL_HANDLER
LOG_SLACK_EMOJI
LOG_SLACK_USERNAME
LOG_SLACK_WEBHOOK_URL
LOG_STACK
LOG_STDERR_FORMATTER
LOG_SYSLOG_FACILITY
MAIL_EHLO_DOMAIN
MAIL_ENCRYPTION
MAIL_FROM_ADDRESS
MAIL_FROM_NAME
MAIL_HOST
MAIL_LOG_CHANNEL
MAIL_MAILER
MAIL_PASSWORD
MAIL_PORT
MAIL_SENDMAIL_PATH
MAIL_URL
MAIL_USERNAME
MEMCACHED_HOST
MEMCACHED_PERSISTENT_ID
MEMCACHED_USERNAME
MEMCACHED_PASSWORD
MEMCACHED_PORT
MYSQL_ATTR_SSL_CA
PAPERTRAIL_PORT
PAPERTRAIL_URL
POSTMARK_MESSAGE_STREAM_ID
POSTMARK_TOKEN
QUEUE_CONNECTION
QUEUE_FAILED_DRIVER
REDIS_CACHE_CONNECTION
REDIS_CACHE_DB
REDIS_CACHE_LOCK_CONNECTION
REDIS_CLIENT
REDIS_CLUSTER
REDIS_DB
REDIS_HOST
REDIS_PASSWORD
REDIS_PORT
REDIS_QUEUE
REDIS_QUEUE_CONNECTION
REDIS_QUEUE_RETRY_AFTER
REDIS_URL
REDIS_USERNAME
SESSION_CONNECTION
SESSION_DOMAIN
SESSION_DRIVER
SESSION_ENCRYPT
SESSION_EXPIRE_ON_CLOSE
SESSION_HTTP_ONLY
SESSION_LIFETIME
SESSION_PARTITIONED_COOKIE
SESSION_PATH
SESSION_SAME_SITE
SESSION_SECURE_COOKIE
SESSION_STORE
SESSION_TABLE
SLACK_BOT_USER_DEFAULT_CHANNEL
SLACK_BOT_USER_OAUTH_TOKEN
SQS_PREFIX
SQS_QUEUE
SQS_SUFFIX
```
### 419 Page Expired error in Laravel
Source: https://rocketeersapp.com/419-page-expired-laravel
When working with Laravel you will encounter this error from time to time. Here's how you can fix this error.
## Why is the page expired?
Laravel uses Cross-Site Request Forgery (CSRF) as a protection mechanism, that protects your app from external HTTP requests to your application.
Requests from the outside cannot always be trusted, because they can try to mingle with the data and sessions of your users.
CSRF works by generating a unique and randomly generated token that only your application knows and therefore it can detect if a request is allowed by verifying this token. The token expires automatically to make sure it cannot be retrieved and used again and again.
## When does this happen
A page expired error can happen when you've forgotten to send the randomly generated CSRF token along with a "POST", "PUT", "PATCH", or "DELETE" request.
This typically happens when making an AJAX request or when submitting a form.
## How to fix the error
When submitting a form, always add a hidden input named `_token` with the value set to `csrf_token()`. More easily you can use the `@csrf` Blade directive which is a shortcut to output this hidden input.
If you're performing an AJAX request, then it's because you've forgotten to add the `X-CSRF-TOKEN` header to the request.
You can add this header automatically to every AJAX request when using the popular [Axios](https://axios-http.com) Javascript HTTP library:
```javascript
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
```
Or when using jQuery:
```javascript
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
```
If users hit the error after leaving a form open for a while, the session (and with it the token) has simply expired. Raise `SESSION_LIFETIME` in your [.env file](/environment-variables-laravel) and [clear the config cache](/clear-cache-laravel) afterwards.
Another option - depending on your use case - is to [disable the verification of the CSRF token](/disable-csrf-in-laravel) for all or specific routes in your application.
In case of stateless requests like API or webhooks this makes sense and is the use of API tokens or signed routes more suitable.
For a deeper walkthrough of every cause and fix, including AJAX headers and expired sessions, see [CSRF token mismatch in Laravel](/csrf-token-mismatch-laravel).
### How to clear cache in Laravel
Source: https://rocketeersapp.com/clear-cache-laravel
Laravel uses caching for a lot of parts of the framework, here's how you can clear all of them.
## How to clear all caches at once
To clear all caches at once, Laravel has a specific optimize command that can clear every major cache in the application. This includes the configured cache driver, the events, views, route, config and bootstrap files:
```bash
php artisan optimize:clear
```
## Application cache
Laravel has versatile cache functionalities baked in the framework. It can handle multiple cache stores like file based, using a database (MySQL, PotgreSQL, SQLite, Redis) or specific software like memcached.
Using the command line you can clear the default configured cache with:
```php
php artisan cache:clear
```
But you can also define another store that's not the default, but is configurred and used within your app:
```php
php artisan cache:clear file # other stores are: apc, array, database, file, memcached, redis, dynamodb, octane
```
Clearing the cache within application code is possible using:
```php
use Illuminate\Support\Facades\Cache;
Cache::flush(); # clear default cache
Cache::store('memcached')->flush(); # clear cache for 'memcached' store
```
## Framework cache
Laravel has multiple ways to improve its performance by caching framework specific functionality. Here are all caches Laravel uses to bootstrap the framework as quickly as possible:
### Config cache
Using `php artisan config:cache` during deployments, Laravel can optimize config files and make them as static as possble to quickly load the config on each request. This also means you cannot update config dynamically anymore using the helper `config([])`. You can clear the config cache using:
```php
php artisan config:clear
```
### Events cache
Using `php artisan events:cache` when deploying, Laravel can collect all event files upfront, which makes loading of a lot of scatered events inside your application much faster.
```php
php artisan events:clear
```
### Routes cache
To further optimize performance, you can use `php artisan routes:cache` to deploy your Laravel app with an optimized routes cache. This collects all registered routes and creates a static cache to match every route as fast as possible.
Clearing the routes cache:
```php
php artisan routes:clear
```
### Views cache
Laravel compiles the Blade syntax to PHP code when executing the views for rendering it in your application. This cache is stored in `storage/framework/views` and contains all Blade views compiled to PHP files.
Clearing this cache can be done with:
```php
php artisan views:clear
```
## Use Laravel without cache
## Array or null cache driver
To really have no cache at all, you can configure `array` or `null` as cache driver. So this means you have no store at all, only during the runtime of a request the cache will work. After each request the cache will be empty again.
```bash
CACHE_DRIVER=null # or "array"
```
### Complete list of Laravel events
Source: https://rocketeersapp.com/laravel-events
Laravel provides quite a lot of events that are fired by default, which makes it easy to hook into using listeners.
Out of the box Laravel has a wide variety of events that are fired inside your application by default.
These events can help you with hooking into functionality and listen for when things are happening. This makes Laravel easily extensible without much effort.
## How to list events in your Laravel app
If you would like to quickly overview all Laravel events inside your own app, including listeners. Execute the following artisan command:
```bash
php artisan event:list
```
## Complete list of events in Laravel
Here is an up-to-date overview per section of the latest Laravel version 10.x and official packages, with events per module.
### Laravel Auth events
```php
Illuminate\Auth\Events\Attempting::class
Illuminate\Auth\Events\Authenticated::class
Illuminate\Auth\Events\CurrentDeviceLogout::class
Illuminate\Auth\Events\Failed::class
Illuminate\Auth\Events\Lockout::class
Illuminate\Auth\Events\Login::class
Illuminate\Auth\Events\Logout::class
Illuminate\Auth\Events\OtherDeviceLogout::class
Illuminate\Auth\Events\PasswordReset::class
Illuminate\Auth\Events\Registered::class
Illuminate\Auth\Events\Validated::class
Illuminate\Auth\Events\Verified::class
```
### Laravel Bus Events
```php
Illuminate\Bus\Events\BatchDispatched::class
```
### Laravel Cache events
```php
Illuminate\Cache\Events\CacheEvent::class
Illuminate\Cache\Events\CacheHit::class
Illuminate\Cache\Events\CacheMissed::class
Illuminate\Cache\Events\KeyForgotten::class
Illuminate\Cache\Events\KeyWritten::class
```
### Laravel Console events
```php
Illuminate\Console\Events\ArtisanStarting::class
Illuminate\Console\Events\CommandFinished::class
Illuminate\Console\Events\CommandStarting::class
Illuminate\Console\Events\ScheduledBackgroundTaskFinished::class
Illuminate\Console\Events\ScheduledTaskFailed::class
Illuminate\Console\Events\ScheduledTaskFinished::class
Illuminate\Console\Events\ScheduledTaskSkipped::class
Illuminate\Console\Events\ScheduledTaskStarting::class
```
### Laravel Contracts events
```php
Illuminate\Console\Contracts\ShouldDispatchAfterCommit::class
Illuminate\Console\Contracts\ShouldHandleEventsAfterCommit::class
```
### Laravel Database events
```php
Illuminate\Database\Events\ConnectionEstablished::class
Illuminate\Database\Events\ConnectionEvent::class
Illuminate\Database\Events\DatabaseBusy::class
Illuminate\Database\Events\DatabaseRefreshed::class
Illuminate\Database\Events\MigrationEnded::class
Illuminate\Database\Events\MigrationEvent::class
Illuminate\Database\Events\MigrationStarted::class
Illuminate\Database\Events\MigrationsEnded::class
Illuminate\Database\Events\MigrationsEvent::class
Illuminate\Database\Events\MigrationsStarted::class
Illuminate\Database\Events\ModelPruningFinished::class
Illuminate\Database\Events\ModelPruningStarting::class
Illuminate\Database\Events\ModelsPruned::class
Illuminate\Database\Events\NoPendingMigrations::class
Illuminate\Database\Events\QueryExecuted::class
Illuminate\Database\Events\SchemaDumped::class
Illuminate\Database\Events\SchemaLoaded::class
Illuminate\Database\Events\StatementPrepared::class
Illuminate\Database\Events\TransactionBeginning::class
Illuminate\Database\Events\TransactionCommitted::class
Illuminate\Database\Events\TransactionCommitting::class
Illuminate\Database\Events\TransactionRolledBack::class
```
### Laravel Foundation events
```php
Illuminate\Foundation\Events\LocaleUpdated::class
Illuminate\Foundation\Events\MaintenanceModeDisabled::class
Illuminate\Foundation\Events\MaintenanceModeEnabled::class
Illuminate\Foundation\Events\PublishingStubs::class
Illuminate\Foundation\Events\VendorTagPublished::class
```
### Laravel HTTP client events
```php
Illuminate\Http\Client\Events\ConnectionFailed::class
Illuminate\Http\Client\Events\RequestSending::class
Illuminate\Http\Client\Events\ResponseReceived::class
```
### Laravel Log events
```php
Illuminate\Log\Events\MessageLogged::class
```
### Laravel Mail events
```php
Illuminate\Mail\Events\MessageSending::class
Illuminate\Mail\Events\MessageSent::class
```
### Laravel Notifications events
```php
Illuminate\Notifications\Events\BroadcastNotificationCreated::class
Illuminate\Notifications\Events\NotificationFailed::class
Illuminate\Notifications\Events\NotificationSending::class
Illuminate\Notifications\Events\NotificationSent::class
```
### Laravel Queue events
```php
Illuminate\Queue\Events\JobExceptionOccurred::class
Illuminate\Queue\Events\JobFailed::class
Illuminate\Queue\Events\JobPopped::class
Illuminate\Queue\Events\JobPopping::class
Illuminate\Queue\Events\JobProcessed::class
Illuminate\Queue\Events\JobProcessing::class
Illuminate\Queue\Events\JobQueued::class
Illuminate\Queue\Events\JobReleasedAfterException::class
Illuminate\Queue\Events\JobRetryRequested::class
Illuminate\Queue\Events\JobTimedOut::class
Illuminate\Queue\Events\Looping::class
Illuminate\Queue\Events\QueueBusy::class
Illuminate\Queue\Events\WorkerStopping::class
```
### Laravel Redis events
```php
Illuminate\Redis\Events\CommandExecuted::class
```
### Laravel Routing events
```php
Illuminate\Routing\Events\PreparingResponse::class
Illuminate\Routing\Events\ResponsePrepared::class
Illuminate\Routing\Events\RouteMatched::class
Illuminate\Routing\Events\Routing::class
```
### Laravel Eloquent events
Laravel Eloquent uses keys instead of FQDN.
```php
eloquent.created
eloquent.creating
eloquent.deleted
eloquent.deleting
eloquent.forceDeleted
eloquent.forceDeleting
eloquent.restored
eloquent.saved
eloquent.saving
eloquent.updated
eloquent.updating
```
## Official Laravel packages
### Laravel Folio events
```php
Laravel\Folio\Events\ViewMatched::class
```
### Laravel Fortify events
```php
Laravel\Fortify\Events\PasswordUpdatedViaController::class
Laravel\Fortify\Events\RecoveryCodeReplaced::class
Laravel\Fortify\Events\RecoveryCodesGenerated::class
Laravel\Fortify\Events\TwoFactorAuthenticationChallenged::class
Laravel\Fortify\Events\TwoFactorAuthenticationConfirmed::class
Laravel\Fortify\Events\TwoFactorAuthenticationDisabled::class
Laravel\Fortify\Events\TwoFactorAuthenticationEnabled::class
Laravel\Fortify\Events\TwoFactorAuthenticationEvent::class
```
### Laravel Horizon events
```php
Laravel\Horizon\Events\JobDeleted::class
Laravel\Horizon\Events\JobFailed::class
Laravel\Horizon\Events\JobPushed::class
Laravel\Horizon\Events\JobReleased::class
Laravel\Horizon\Events\JobReserved::class
Laravel\Horizon\Events\JobsMigrated::class
Laravel\Horizon\Events\LongWaitDetected::class
Laravel\Horizon\Events\MasterSupervisorDeployed::class
Laravel\Horizon\Events\MasterSupervisorLooped::class
Laravel\Horizon\Events\MasterSupervisorOutOfMemory::class
Laravel\Horizon\Events\MasterSupervisorReviving::class
Laravel\Horizon\Events\RedisEvent::class
Laravel\Horizon\Events\SupervisorLooped::class
Laravel\Horizon\Events\SupervisorOutOfMemory::class
Laravel\Horizon\Events\SupervisorProcessRestarting::class
Laravel\Horizon\Events\UnableToLaunchProcess::class
Laravel\Horizon\Events\WorkerProcessRestarting::class
```
### Laravel Jetstream events
```php
Laravel\Jetstream\Events\AddingTeam::class
Laravel\Jetstream\Events\AddingTeamMember::class
Laravel\Jetstream\Events\InvitingTeamMemberFlushed::class
Laravel\Jetstream\Events\RemovingTeamMember::class
Laravel\Jetstream\Events\TeamCreated::class
Laravel\Jetstream\Events\TeamDeleted::class
Laravel\Jetstream\Events\TeamEvent::class
Laravel\Jetstream\Events\TeamMemberAdded::class
Laravel\Jetstream\Events\TeamMemberRemoved::class
Laravel\Jetstream\Events\TeamMemberUpdated::class
Laravel\Jetstream\Events\TeamUpdated::class
```
### Laravel Octane events
```php
Laravel\Octane\Events\HasApplicationAndSandbox::class
Laravel\Octane\Events\RequestHandled::class
Laravel\Octane\Events\RequestReceived::class
Laravel\Octane\Events\RequestTerminatedived::class
Laravel\Octane\Events\TaskReceived::class
Laravel\Octane\Events\TaskTerminated::class
Laravel\Octane\Events\TickReceived::class
Laravel\Octane\Events\TickTerminated::class
Laravel\Octane\Events\WorkerErrorOccurred::class
Laravel\Octane\Events\WorkerStarting::class
Laravel\Octane\Events\WorkerStopping::class
```
### Laravel Passport events
```php
Laravel\Passport\Events\AccessTokenCreated::class
Laravel\Passport\Events\RefreshTokenCreated::class
```
### Laravel Pennant events
```php
Laravel\Pulse\Events\AllFeaturesPurged::class
Laravel\Pulse\Events\DynamicallyRegisteringFeatureClass::class
Laravel\Pulse\Events\FeatureDeleted::class
Laravel\Pulse\Events\FeatureResolved::class
Laravel\Pulse\Events\FeatureRetrieved::class
Laravel\Pulse\Events\FeatureUpdated::class
Laravel\Pulse\Events\FeatureUpdatedForAllScopes::class
Laravel\Pulse\Events\FeaturesPurged::class
Laravel\Pulse\Events\UnexpectedNullScopeEncountered::class
Laravel\Pulse\Events\UnknownFeatureResolved::class
```
### Laravel Pulse events
```php
Laravel\Pulse\Events\ExceptionReported::class
Laravel\Pulse\Events\IsolatedBeat::class
Laravel\Pulse\Events\SharedBeat::class
```
### Laravel Sanctum events
```php
Laravel\Sanctum\Events\TokenAuthenticated::class
```
### Laravel Scout events
```php
Laravel\Scout\Events\ModelsFlushed::class
Laravel\Scout\Events\ModelsImported::class
```
To record what your listeners do, combine events with [Laravel logging](/laravel-logging). For caching expensive listener work, see the [Laravel cache](/laravel-cache).
### Laravel Valet
Source: https://rocketeersapp.com/laravel-valet
For Mac users Laravel Valet is an execellent tool to develop all your PHP projects locally.
## What is Laravel Valet?
Laravel Valet is a tool that makes developing locally more enjoyable, because it makes your Mac instantly ready for development on your local computer. It's a minimalist tool, that uses Homebrew to install everything you need and get started quickly.
Laravel Valet uses nginx, DnsMasq and PHP binaries to make it easy to develop using PHP and being able to switch to different PHP versions.
## Not only for Laravel apps
Despite its name, Laravel Valet is not only for developing Laravel apps. Valet supports out of the box the following types of projects to run locally on your computer.
- Laravel
- Bedrock
- CakePHP 3
- ConcreteCMS
- Contao
- Craft
- Drupal
- ExpressionEngine
- Jigsaw
- Joomla
- Katana
- Kirby
- Magento
- OctoberCMS
- Sculpin
- Slim
- Statamic
- Static HTML
- Symfony
- WordPress
- Zend
## How to install Laravel Valet
First ensure Homebrew is up to date with the latest software packages:
```bash
brew update
```
Then, install PHP using:
```bash
brew install php
```
Make sure that `~/.composer/vendor/bin` is in your system's PATH, so that after composer is installed, you can install Laravel Valet as a global package:
```bash
composer global require laravel/valet
```
At last, install Valet using the `install` command:
```bash
valet install
```
After that, you can use any \*.test domain to use locally on your projects.
## How does it work
Laravel Valet uses a simple paradigm: every domain is a folder inside "parked" directory. So when you want to use the Mac folder `~/Sites` to clone all your projects on your computer in, you can `park` inside this directory to make all folders available as a `[folder].test` domain.
```bash
cd ~/Sites
valet park
```
## Using different PHP versions
Install different PHP versions you need for you local repositories, you can install additional PHP version using:
```bash
brew install php@8.3
```
Replace the version number with the version you want to install. After that you can tell Laravel Valet to use this PHP version for your project or as a new default PHP version for all sites.
To use PHP 8.3 for all sites:
```bash
valet use php@8.3 # use --force when this is not enough
```
Or only for a specific site:
```bash
valet isolate php@8.3 # execute this inside the correct ~/Sites/[site] folder
```
You can also set a specific version for Laravel Valet to use, when using the `valet use` (without a PHP version) command. The PHP version can be determined from a `.valetrc` file in the root of the project:
```bash
php=php@8.3
```
After adding this file, you can execute `valet use` to always use the specified PHP version with Laravel Valet.
On a production server you'd instead [install multiple PHP versions](/how-to-install-multiple-php-versions-on-same-server) and pick one per site. To confirm a project's framework release, [check your Laravel version](/check-laravel-version).
### Creating an encrypted cookie value in Laravel
Source: https://rocketeersapp.com/creating-an-encrypted-cookie-value-in-laravel
Laravel can encrypt cookies automatically using the `\App\Http\Middleware\EncryptCookies::class` middleware. It's so easy, you don't have to think about it when adding your cookies to the response.
But when you want to hit your own application with a request that needs a certain cookie, you can't simply add the cookie to the HTTP request and you also cannot encrypt it using the `encrypt()` helper. This is because cookies need a different treatment when creating the exact encrypted cookie value.
When digging in the source of Laravel we found the following code and we wrapped it up in a helper, because we have a specific use case for an application that needs to perform HTTP requests to itself, with some encrypted cookies.
Here we go, this helper can be added to any file your keeping your little helpers in:
```php
if (! function_exists('encrypted_cookie_value')) {
function encrypted_cookie_value(string $name, string $value): string
{
$encrypter = app(Illuminate\Encryption\Encrypter::class);
return $encrypter->encrypt(
CookieValuePrefix::create($name, $encrypter->getKey()).$value,
false
);
}
}
```
Keep the related secrets in your [environment variables](/environment-variables-laravel). If you'd rather switch cookies off entirely, see [disabling cookies in Laravel](/disable-cookies-in-laravel).
### Disable CSRF in Laravel
Source: https://rocketeersapp.com/disable-csrf-in-laravel
Sometimes you need to disable the CSRF token verification in Laravel. A common use case is when you want to receive POST webhooks.
## What is CSRF?
Cross-Site Request Forgery (CSRF) is a protection mechanism. This security is added and enabled by default in Laravel. CSRF protects your app for requests from outside your application. It uses a random generated token that only your application knows and therefore it can detect if a request is allowed by verifying this token.
## Examples of not needing CSRF
Sometimes you don't need the extra protection or want to do something that makes CSRF verification difficult. In case of receiving webhooks it is sometimes necessary and also when working with incoming API requests for routes that are not defined in the `routes/api.php` and therefore do not use the API middleware group.
I'm not going to argue the best practices here, but I am going to show you how you can disable the CSRF token check in Laravel.
If you landed here because of an error rather than webhooks, you probably want [CSRF token mismatch in Laravel](/csrf-token-mismatch-laravel) or the [419 Page Expired error](/419-page-expired-laravel) instead, disabling CSRF is rarely the right fix for those.
## Finding the CSRF middleware
From Laravel v5.1 you can find the CSRF verification middleware inside `app/Http/Middleware/VerifyCsrfToken.php`. There you find a protected array variable `$except`. This array you can fill in different ways, here are some options:
## Disabling CSRF for every route
You can disable CSRF completely for all routes in your application using the asterisk (\*) wildcard:
```php
protected $except = [
'*',
];
```
## Disabling CSRF for path using wildcard
Disabling a specific kind of path using a wildcard, is also possible:
```php
protected $except = [
'webhooks/*',
];
```
## Disabling CSRF for specific paths
In this example only specific non-wildcard paths are defined and exempt from CSRF protection:
```php
protected $except = [
'webhooks/mailgun',
'webhooks/postmark',
];
```
### How to check which Laravel version of your app is using
Source: https://rocketeersapp.com/check-laravel-version
Check which Laravel version your app is running on.
## Command Line using Artisan
If you want to know the exact version of your Laravel app, the easiest and fastest option is using the command line:
```bash
php artisan --version
# Example output: Laravel Framework 9.33.0
```
## Command Line using Composer
It's also possible to determine the Laravel version using Composer, because Laravel is (or should be) installed using this package manager. Piping the JSON formatted output of Composer to `jq` makes the value directly accessible:
```bash
composer show laravel/framework --format json | jq '.versions'
# Example output: ["v9.33.0"]
```
## Using the `app()` helper
Within the application code, you can use the `app()` helper to discover the current Laravel version:
```php
echo app()->version();
// Example output: 9.33.0
```
Using the helper, you could show the Laravel version in a Blade template to expose it in a development environment or when a admin needs to have some debug info:
```php
{{ app()->version() }}
```
Working locally on a Mac? [Laravel Valet](/laravel-valet) makes switching PHP and framework versions painless. Configuration per environment lives in your [environment variables](/environment-variables-laravel).
### Logging in Laravel
Source: https://rocketeersapp.com/laravel-logging
When you have an web app running, it is important to know what it does and how it's going. To get more insight into this, using the extensive logging features of Laravel is key.
## When you need logging
When developing an application, you most likely want to understand what your code actually does. When your coding it is not always possible to understand what the outcome will be without testing it or logging the output to a certain place. By dispatching and processing jobs on a queue on the background, using scheduled commands (cronjobs) and other techniques the need for logging what the code does will be higher than working on a fairly simple app.
## How does logging work in Laravel
Laravel uses the widely used Monolog package under the hood for all its logging capabilities. This logging solution in PHP is very extensive and flexible and Laravel has integrated it very well inside their framework.
By using a comprehensive config for logging, you can combine multiple log streams in Laravel. This makes it easy to output to different locations and keeps the logging posibilities very flexible.
Laravel makes this possible by using the terminology of channels. Channels are meant to provide multiple types of logging out of the box and using the `stack` driver you can configure multiple channels at the same time. So your application can log into multiple locations at once.
## Configuration
The default configuration is a great example of how this works. Here are three different channels defined, a `syslog`, `slack` and `stack` driver. Where the last driver is a special one, because this can combine multiple channels (in this case `syslog` and `slack` together) at once:
```php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['syslog', 'slack'],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
],
```
## Extensible
Anything is possible for logging in your Laravel application. You can [even write your own driver fairly easy](https://laravel.com/docs/9.x/logging#creating-custom-channels-via-factories) by writing a custom logger.
## Writing log statements
When you want to write log statements to one or more channels, you can use the `Log` facade or the `logger()` helper inside your Laravel app. This makes it really easy to output any message you want to the configured log channels:
```php
use Illuminate\Support\Facades\Log;
Log::debug($message); // logger()->debug($message);
```
## Using different log levels
When logging messages, it is a best practice to define the correct level the message is intended for. For example, it could be handy to use `info` for only informational purposes. To follow along the execution of your code for example. But when you want to log critical errors to your log files, than it's best to also use the `critical()` method for this use case. Here are the methods and functions of every level that is available in Laravel:
```php
use Illuminate\Support\Facades\Log;
Log::emergency($message); // logger()->emergency($message);
Log::alert($message); // logger()->alert($message);
Log::critical($message); // logger()->critical($message);
Log::error($message); // logger()->error($message);
Log::warning($message); // logger()->warning($message);
Log::notice($message); // logger()->notice($message);
Log::info($message); // logger()->info($message);
Log::debug($message); // logger()->debug($message);
```
Laravel also fires a wide range of [events](/laravel-events) you can hook into for the same visibility. When you need errors surfaced in real time, Rocketeers provides [site error reporting and management](/features/sites-error-reporting-and-management), with alerts to email, [Slack](/providers/slack), Discord, and Telegram.
### Disable cookies in Laravel
Source: https://rocketeersapp.com/disable-cookies-in-laravel
When you don't need cookies, it's a good practice to prevent your app from creating them in the first place.
When you have a Laravel project that doesn't need cookies in any way, it's great to know how to completely prevent the usage of cookies. It makes your application cookieless and stateless. In Laravel they are send down the line by default and you may not need them or don't want them (in case of GDPR compliance for example).
## When Laravel needs cookies
Laravel uses cookies by default to attach the session to the same visitor and to keep a CSRF-token for sending forms to your application. Because most applications will need to use cookies for authentication and/or cross site request forgery (CSRF) protection.
> If you don't have authentication or forms on your website, than you don't need cookies and you can safely disable setting these cookies by Laravel.
[→ How to disable CSRF in Laravel](/disable-csrf-in-laravel)
## How to prevent Laravel from using cookies
To disable cookies the usage of cookies in Laravel, we need to make changes to the middleware stack that is setup by default. By default every visitor starts a new session and also has a unique CSRF token.
To prevent this, we need to shift a few middlewares to a different middleware group. This is done in the code below, where we moved the middlewares to the new `cookies` group and removed them from `web`.
```php
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'cookies' => [
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
],
];
```
## Only use cookies when you really need them
When adding routes that do need authentication or have forms, you can now easily attach the `cookies` middleware group conditionally to these routes, to enable the necessary cookies:
```php
Route::middleware('cookies')->group(function () {
Route::get('login', [LoginController::class, 'form']);
Route::post('login', [LoginController::class, 'login']);
});
```
In situations where you cannot use a route group because of packages or other restrictions, you can also enable them conditionally using the `boot()` method on the `AppServiceProvider` and push them to the default `web` middleware group based on a URL pattern:
```php
public function boot()
{
// Only add cookies for requests within Laravel Nova
if(request()->is('nova/*')) {
$this->app['router']->pushMiddlewareToGroup('web', \Illuminate\Session\Middleware\StartSession::class);
$this->app['router']->pushMiddlewareToGroup('web', \Illuminate\View\Middleware\ShareErrorsFromSession::class);
$this->app['router']->pushMiddlewareToGroup('web', \App\Http\Middleware\VerifyCsrfToken::class);
}
}
```
If instead you need to *send* an encrypted cookie to your own application, see [creating an encrypted cookie value in Laravel](/creating-an-encrypted-cookie-value-in-laravel).
### How to use different PHP versions with Laravel Valet
Source: https://rocketeersapp.com/different-php-versions-laravel-valet
Laravel Valet is an awesome tool to quickly setup an development environment on your Mac. Here's how you can switch between different PHP versions while developing multiple projects.
While Rocketeers app will allow you to use different PHP versions on your webservers, it previously was very difficult to use different PHP versions on your local machine using Laravel Valet.
Since june 2022 Laravel Valet got a very nice upgrade in using different PHP versions and easily switch between them when working concurrently on different projects.
## Isolate a PHP version per project
The first command that's new is `valet isolate` this command can be used to isolate a project using one specific PHP version. After that the nginx server that Valet installs on your Mac knows which PHP version to use for your project.
For example the command for a project locked into using only PHP 8.1 is:
```bash
valet isolate php@8.1
```
## Use the specific PHP version even in your command line
Valet provides the ease of use to map local folders to hostnames in the browser and run them locally in the browser, using the correct PHP version. When using PHP from the command line, it is also possible to automatically let your Terminal know which PHP version to use when you run commands from the base path of your project.
For this specific use case Valet offers an `valet php` command. This command aliases the PHP version within the folder of your project to the isolated PHP version.
## Use aliases for the ultimate DX
To further optimize the DX you can make yourself even more comfortable. Include this alias in your `~/.zshrc` or `~/.bashrc` and you can keep using `php` to execute commands using the correct PHP version in your CLI:
```bash
alias php="valet php"
```
In order to run also Composer on the same PHP version, set also an alias up with absolute path to Composer to keep using it like `composer require ...`:
```bash
alias composer="php /usr/local/bin/composer"
```
See also [Laravel Valet](/laravel-valet) for the local development setup, and [installing multiple PHP versions](/how-to-install-multiple-php-versions-on-same-server) for production servers.
## Nginx
### Use nginx try_files to make your site static
Source: https://rocketeersapp.com/nginx-try-files
The `try_files` directive in nginx is incredibly powerful and useful when you want to make your dynamic website more performant. Learn how to leverage this to make your website fully static.
## How `try_files` works
This directive makes use of a fallback system. The first (of possibly multiple) file paths that exists, will be used for the incoming HTTP request.
So, for example you can set it to:
```bash
try_files index1.html index2.php
```
If `index1.html` exists, it is served by nginx. If it does not exist, it will serve using the second file `index2.php`.
## How to use `try_files` for static or cached content
As you already saw in the previous example, it is easy to put a static file in front of a dynamic file. But the only problem here is that in this case always `index.html` will be served.
You can solve this by making the first entry dynamic using variables in nginx. Requests have multiple variables that are dynamic based on the specific request the web server is receiving.
Some of these are:
```bash
$request_method # (e.g. GET/HEAD/POST/PUT/DELETE)
$scheme # http or https
$host # domain
$uri # path
$query_string # query string (e.g. `?a=b`)
```
You can find all variables on the [official nginx website](https://nginx.org/en/docs/http/ngx_http_core_module.html).
Using these variables, you can make a unique file location for each request. So this could be:
```
/cache/$host/$http_method/$uri?$query_string.html
```
This creates a path that points to the `cache` folder (relative to the document root) and inside this folder you have a custom path.
### $host
In this example it begins with a `$host` folder, this is needed when you host multiple domains from the same virtual host.
### $request_method
Then it creates a folder based on the $http_method (like GET or POST) and this is because of the simple reason we don't want to cache other requests than `GET`. So when creating the files, we only create a `/GET/` path inside this folder.
### $uri
After that we have the `$uri` which can contain slashes and therefore creates folders, while the last segment is the file. So `https://rocketee.rs/category/nginx/try_files` would create the path `category/nginx` and the file `try_files`.
#### $query_string
Not necessary, but could be useful is adding the `$query_string` variable. This makes the request unique per different query string that is used. If the content on your webpages is not affected by query strings, you could remove it and have the same cache file respond to it.
## Configuring `try_files`
Now putting this together, we got this configuration rule for `try_files`:
```bash
try_files /cache/$host/$http_method/$uri?$query_string.html index.php
```
This checks first the cache path and if it does not exist it executes index.php. So when this happens, you can use the dynamic response of index.php to create a cached file.
## Creating the cache file using PHP
In simple plain PHP this would look like this:
```php
$response = '...'; // HTML response to cache
$uri = $_SERVER['REQUEST_URI'];
$path = parse_url($uri, PHP_URL_PATH);
$query = parse_url($uri, PHP_URL_QUERY);
file_put_contents(
filename: "/cache/{$_SERVER['HTT_HOST']}/{$_SERVER['REQUEST_METHOD']}/{$path}?{$query}.html",
data: $response,
);
```
This way you can have a completely dynamic website, that still leverages the fastest cache available (static files).
If `try_files` sends requests round in circles, see [rewrite or internal redirection cycle](/nginx-rewrite-or-internal-redirection-cycle). For including content fragments at the server level, see [server side includes (SSI) in nginx](/server-side-includes-ssi-nginx).
### Log bot requests in nginx
Source: https://rocketeersapp.com/log-bot-requests-nginx
If you want to know when and how often bots visit your website, you can easily track this using the following configuration in nginx.
## Detect bots using nginx
First we need to detect if the current visitor's user agent indicates it is a bot. We can do this with the `map` directive in nginx:
```bash
map $http_user_agent $bot {
default "";
"~*googlebot" "google";
"~*bingbot" "bing";
"~*slurp" "yahoo";
"~*duckduckbot" "duckduckgo";
"~*baiduspider" "baidu";
"~*yandexbot" "yandex";
"~*sogou" "sogou";
"~*exabot" "exabot";
"~*applebot" "apple";
"~*twitterbot" "twitter";
}
```
This checks the user agent string for matches for known bot names and then it maps it to a specific name that is set to variable `$bot`.
## Readble log format
To make the entries readable, you can optionally choose to define a specific `log_format` for the bot requests:
```bash
log_format bots "$time_local: $request_method $scheme://$host$request_uri [$status] $bytes_sent @ $request_time ($http_referer)";
```
## Log requests when it's a bot
Now we can log these bot requests by creating a specific `bots.log` file using the `access_log` directive that logs requests only if `$bot` is filled and set the `log_format` to the newly created `bots` format.
```bash
access_log /var/www/logs/bots.log bots if=$bot;
```
## Log files per bot
If you prefer separating the logs per bot, so you can more easily see how many times specifically the Googlebot has come by your website, you can define a variable per bot:
```bash
if ($bot = "google") {
set $google "1";
}
if ($bot = "bing") {
set $bing "1";
}
if ($bot = "yahoo") {
set $yahoo "1";
}
if ($bot = "duckduckgo") {
set $duckduckgo "1";
}
if ($bot = "baidu") {
set $baidu "1";
}
if ($bot = "yandex") {
set $yandex "1";
}
if ($bot = "sogou") {
set $sogou "1";
}
if ($bot = "exabot") {
set $exabot "1";
}
if ($bot = "apple") {
set $apple "1";
}
if ($bot = "twitter") {
set $twitter "1";
}
```
And therefore setup log files per bot:
```bash
access_log /var/www/logs/bots/google.log bots if=$google;
access_log /var/www/logs/bots/bing.log bots if=$bing;
access_log /var/www/logs/bots/yahoo.log bots if=$yahoo;
access_log /var/www/logs/bots/duckduckgo.log bots if=$duckduckgo;
access_log /var/www/logs/bots/baidu.log bots if=$baidu;
access_log /var/www/logs/bots/yandex.log bots if=$yandex;
access_log /var/www/logs/bots/sogou.log bots if=$sogou;
access_log /var/www/logs/bots/exabot.log bots if=$exabot;
access_log /var/www/logs/bots/apple.log bots if=$apple;
access_log /var/www/logs/bots/twitter.log bots if=$twitter;
```
To match a single well-behaved crawler instead, see [detecting Googlebot in nginx](/detect-googlebot-nginx).
### Detect Googlebot visits using nginx
Source: https://rocketeersapp.com/detect-googlebot-nginx
How to detect when and how often Googlebot visits your website using a few configuration lines in nginx.
## Detecting Googlebot
It's very easy to detect the Googlebut using the user agent in nginx, here we use the `map` directive to set the variable `$googlebot` to `yes` or keep it empty depending on the given user agent:
```bash
map $http_user_agent $googlebot {
default "";
"~*googlebot" "yes";
}
```
## Logging the requests
When `$googlebot` is filled, we want to log the request in a log file. This can be done using the `access_log` directive:
```bash
access_log /var/www/logs/googlebot.log bots if=$googlebot;
```
That's it!
You can read here [how you can log multiple bots](/log-bot-requests-nginx) how to log multiple bots for your virtual host in nginx.
### How to install Nginx on Ubuntu
Source: https://rocketeersapp.com/how-to-install-nginx
Nginx is the web server that sits in front of your application and answers every request. Here is how to install it on Ubuntu, serve your first site, and set it up the way a production server should be.
[Nginx](https://nginx.org) is the piece that listens on ports 80 and 443, terminates TLS, serves your static files, and passes everything else to [PHP-FPM](/how-to-install-php) or your application. It's fast, lightweight, and runs the majority of the busy sites on the web. Here's how to get it running on a fresh Ubuntu server.
## Install Nginx
The quickest route is the package in Ubuntu's own repository:
```bash
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y nginx
```
If you want the latest stable release rather than whatever Ubuntu shipped, add the official Nginx repository first:
```bash
echo "deb http://nginx.org/packages/ubuntu/ $(lsb_release -sc) nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.list
```
Either way, start it and have it come back automatically after a reboot:
```bash
sudo systemctl enable --now nginx
```
Visit your server's IP address in a browser and you should see the default Nginx welcome page.
## Open the firewall
If you're running `ufw`, Nginx ships profiles that open the right ports. Allow HTTP and HTTPS:
```bash
sudo ufw allow 'Nginx Full'
```
Without this, your site is reachable from the server itself but nothing else — a common reason a freshly installed site "doesn't load."
## Understand the directory layout
A production Nginx setup keeps one config file per site and switches them on by symlink:
```bash
sudo mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
```
- `sites-available/` holds a `.conf` file for every site, whether it's live or not.
- `sites-enabled/` holds symlinks to the ones that are actually active.
Make sure the main `nginx.conf` includes the enabled sites (most distributions already do):
```nginx
include /etc/nginx/sites-enabled/*;
```
## Serve your first site
Create a server block for your domain in `/etc/nginx/sites-available/example.com.conf`:
```nginx
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
```
The `try_files` directive is the heart of most routing problems — if you hit a wall, read [understanding the Nginx try_files directive](/nginx-try-files). Enable the site by symlinking it, test the config, and reload:
```bash
sudo ln -s /etc/nginx/sites-available/example.com.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo service nginx reload
```
Always run `sudo nginx -t` before reloading. A single typo can take every site on the server down with a [502 Bad Gateway](/502-bad-gateway-nginx) or [403 Forbidden](/403-forbidden-nginx).
## Harden TLS with a strong DH group
If you'll terminate HTTPS on this server (see [how to install Certbot](/how-to-install-certbot)), generate a strong Diffie-Hellman parameter file once, up front — it takes a while on 4096 bits but only has to happen one time:
```bash
sudo openssl dhparam -dsaparam -out /etc/nginx/dhparam.pem 4096
```
Reference it in your TLS config to push toward an [A+ SSL grade](/a-plus-grade-ssl-using-cloudflare). While you're tuning, it's worth [enabling gzip compression](/enable-gzip-compression-nginx) too.
## Let Rocketeers handle it
Installing Nginx takes a few minutes. Running it well is the ongoing job: per-site server blocks, FastCGI tuning, the right PHP socket per site, compression, security headers, a strong DH group, and syncing real visitor IPs when you sit behind Cloudflare. Rocketeers provisions [Nginx the production way](/features/nginx-configured-for-optimal-performance) and generates a correct, tested vhost for every site you deploy — so you never hand-edit a config file or reload a broken one.
### nginx rewrite or internal redirection cycle
Source: https://rocketeersapp.com/nginx-rewrite-or-internal-redirection-cycle
This error means nginx kept redirecting a request back to itself until it gave up. Almost always a try_files directive that loops, producing a 500 error.
## About the error
The page returns a 500 and the nginx error log shows:
```bash
rewrite or internal redirection cycle while internally redirecting to "/index.php"
```
nginx tried to resolve a request, that resolution pointed at another location, which pointed back, and so on. After 10 internal redirects nginx assumes a loop and aborts with a 500.
## Why do I see this error
The classic cause is a `try_files` that, when nothing matches, falls back to a target that itself triggers the same `try_files` again. For a PHP app it usually means:
- The fallback file (`index.php`) doesn't exist at the expected path, so the fallback re-enters the same block.
- The `root` is wrong, so nginx never finds the real file and keeps redirecting.
- A `try_files` pointing at a named location or URI that loops back.
## Solution
### Use the standard Laravel/PHP try_files
A correct front-controller setup looks like this. Note the `$uri` and `$uri/` are tried first, and the final fallback passes the path as a query string rather than re-requesting a file:
```nginx
server {
root /var/www/html/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
}
}
```
### Check the root actually contains index.php
The most common real cause is a wrong `root`. If `/var/www/html/public/index.php` doesn't exist, the fallback can never resolve and loops:
```bash
ls -l /var/www/html/public/index.php
```
Point `root` at the directory that genuinely contains the front controller (for Laravel that's `public`, not the project root).
Validate and reload after fixing:
```bash
nginx -t && systemctl reload nginx
```
For a deeper look at how `try_files` resolves requests, see [the nginx try_files article](/nginx-try-files). A redirect loop that happens in the browser rather than inside nginx shows up as [ERR_TOO_MANY_REDIRECTS](/err-too-many-redirects) instead.
### nginx: upstream sent too big header
Source: https://rocketeersapp.com/nginx-upstream-sent-too-big-header
This 502 variant happens when your application sends response headers larger than the buffers nginx reserves for them. Common with big cookies, large session data, or many headers.
## About the error
The visitor gets a [502 Bad Gateway](/502-bad-gateway-nginx) and the nginx error log explains why:
```bash
upstream sent too big header while reading response header from upstream
```
nginx reserves a fixed-size buffer for the response headers it reads back from your application (PHP-FPM or a proxied service). If the headers don't fit, nginx can't process the response and returns a 502.
## Why do I see this error
- **Large cookies**, especially a big session cookie or many cookies set at once.
- A long `Set-Cookie` from a fat session payload.
- Many or unusually large custom headers.
- A redirect with a very long `Location` URL.
It frequently appears right after login, when the session and its cookie grow.
## Solution
Increase the FastCGI buffer sizes so the headers fit. For a PHP-FPM app:
```nginx
fastcgi_buffer_size 32k;
fastcgi_buffers 8 16k;
fastcgi_busy_buffers_size 64k;
```
For a proxied application (`proxy_pass`), the equivalents are:
```nginx
proxy_buffer_size 32k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 64k;
```
Put these in the relevant `location` or `server` block, then validate and reload:
```bash
nginx -t && systemctl reload nginx
```
### Fix the root cause too
Bigger buffers treat the symptom. If the headers are huge because you're storing a lot in the session (and therefore the cookie), trim it. In Laravel, keep large data server-side by using a non-cookie session driver such as `redis` or `database` rather than the `cookie` driver, so only a small session id travels in the header:
```ini
SESSION_DRIVER=redis
```
See [Redis connection refused in Laravel](/redis-connection-refused-laravel) if you switch to the Redis driver.
### Configure Content Security Policy with nonce using nginx
Source: https://rocketeersapp.com/content-security-policy
How to configure Content Security Policy (CSP) with secure and easy to configure nonces in nginx.
## What is a Content Security Policy (CSP)
A Content Security Policy (CSP) is an added layer of security that helps to detect and mitigate certain types of attacks, including Cross Site Scripting (XSS) and data injection attacks. These attacks are used for everything from data theft to site defacement or distribution of malware.
In simple terms, CSP is a list of approved sources for the types of resources that can be loaded on a page. For example, you can specify that images may only come from a certain domain, or that scripts may only come from another domain. When a browser requests a page, it will only load or run scripts/resources from the approved domains.
## What is a nonce
A nonce is a random string that is generated for each request. It is used to uniquely identify a request and is often used to prevent CSRF attacks. The nonce can be attached to the tags of scripts and stylesheets, to make sure only these tags are allowed to be parsed by the browser.
## Where to define CSP on
On multiple levels you can define your CSP configuration, you can define it on the server, on the application level or heck, even on the client side in your HTML. But the problem with defining CSP on client side, is that full static caching is not possible anymore.
The choice where to put your CSP configuration is based mostly on the situation you need it for. When you want every request secured the same way and have a lot of static content you need to cache, the server level is the place to define CSP. Because when requests aren't always touching the application layer, it is really the only place you can configure CSP. But when you have a lot of dynamics and need to define CSP flexibly, the application level is more convienent.
When it's about performance, the server level is unbeaten. Especially someone like me who is a big fan of static caching, the server level is the way to go.
## Requirements
We need a server with nginx installed and the sub_filter module enabled. The sub_filter module is used to replace the nonce placeholder with the generated nonce.
How to check if the sub_filter module is enabled:
```bash
nginx -V 2>&1 | tr ' ' '\n' | grep -qi 'http_sub_module' && echo "installed" || echo "not installed"
```
If the module is present, the ouput of the command above will be `installed`.
## Configuring nginx
### We need a nonce
A nonce should be a lenghty random string and therefore unique for each request. In nginx we could create a variable containing a unique random string, but this ends up installing extensions to be able to do so. So instead we use the SSL session ID as a nonce. This is a unique string for each request and is already available in nginx. Fortunately the session ID contains only characters that are allowed in a nonce.
```bash
set $cspNonce $ssl_session_id;
```
### Injecting the nonce into the response
Now we have a nonce, we need to inject it into the responses that nginx serves:
```bash
sub_filter_once off;
sub_filter_types *;
sub_filter NGINX_CSP_NONCE $cspNonce;
```
Here we use `NGINX_CSP_NONCE` as the placeholder for the nonce. This placeholder can be used in the output of your application, and as long it goes through nginx, it will be replaced with a fresh and unique nonce:
```html
```
### Sending the nonce with the CSP headers
Now to apply the nonce to the CSP headers sent by nginx to the client, we can use the `add_header` directive:
```bash
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$cspNonce'; style-src 'self' 'nonce-$cspNonce' always";
```
This CSP policy indicates that every source should come from the same domain as the page itself. The scripts and stylesheets should also contain the nonce that was generated for this request, to verify that only scripts and styles are loaded that are meant to load.
That's it, now you have a CSP policy that is unique for each request and is compatible with full static caching. There are a lot more options to configure CSP, but this is the basic setup. More rules can be found at [Content Security Policy (CSP) Quick Reference Guide](https://content-security-policy.com/).
### Server Side Includes (SSI) in nginx
Source: https://rocketeersapp.com/server-side-includes-ssi-nginx
Server Side Includes can be a very handy feature when dealing with caching or including (dynamic) files into static files. Here's how to use it and configure nginx to enable the power of SSI.
## What are Server Side Includes (SSI)
Server Side Includes (SSI) is a simple scripting language used on web servers to include content dynamically in web pages. SSI directives are embedded within HTML pages and are processed by the web server before the page is sent to the client's browser. The server executes the directives and includes the specified content in the final HTML document that is delivered to the user.
SSI is typically used for tasks such as:
1. Including Content: You can include the content of one file into another. This is useful for creating reusable components or headers and footers that appear on multiple pages.
```html
```
2. Date and Time Stamps: You can insert the current date and time into your web pages.
```html
```
3. Conditional Statements: SSI supports simple conditional statements, allowing you to include or exclude content based on certain conditions.
```html
Content for query string 1.
Content for other cases.
```
4. Variable Setting and Displaying: You can set variables and display their values.
```html
```
To use SSI, your web server needs to be configured to recognize and process SSI directives. The file extension ".shtml" is often associated with SSI-enabled files, but the configuration can vary depending on the server software being used (e.g., Apache, nginx). Make sure that the server administrator has enabled SSI processing for the desired file extensions.
## When can SSI be useful?
While including files into another file is a typical task for dynamic scripting languages, there are some situations that SSI is comes in handy. The following situations suit SSI very well:
1. Hosting environment where scripting languages poses a security problem
2. The hosting can't be configured for executing server side scripting
3. When the file that needs to have includes is a static (HTML) file
4. The server resources are limited; SSI is very performant at high traffic
5. It is straightforward, lightweight and makes HTML a little bit dynamic
6. You have no excuses anymore for an outdated copyright year number in the footer
## How to enable SSI in nginx
### Add nginx apt repository
```bash
echo "deb http://nginx.org/packages/ubuntu/ $(lsb_release -sc) nginx
deb-src http://nginx.org/packages/ubuntu/ $(lsb_release -sc) nginx" > /etc/apt/sources.list.d/nginx.list
sudo curl -L https://nginx.org/keys/nginx_signing.key | sudo apt-key add -
sudo apt-get update
```
### Install nginx using nginx-full
```bash
sudo apt install -y nginx-full
```
Setting up a fresh server? The full setup is covered in [how to install nginx](/how-to-install-nginx).
### Configure SSI
Add in the location block that needs to use SSI, the following rule:
```bash
location / {
...
ssi on;
...
}
```
### Reload nginx service
Reload the nginx service to apply the configuration changes without downtime:
```bash
sudo service nginx reload
```
Since SSI assembles plain HTML pages, it pairs well with [gzip compression in nginx](/enable-gzip-compression-nginx) to keep those pages small on the wire.
## PHP
### Enable JIT in PHP 8.x OPcache
Source: https://rocketeersapp.com/php-8-opcache-jit
Using OPcache can greatly improve [PHP performance](/php-performance). By enabling Just In Time (JIT) compiling in OPcache you can improve it even more.
## What is JIT (Just In Time) compiling?
JIT is a compiler optimization feature introduced in PHP 8 and can enable faster execution times for CPU intensive tasks. It enhances performance by translating frequently executed PHP code into machine code at runtime, allowing it to be executed directly by the CPU instead of being interpreted line-by-line. This results in significant speed improvements!
This is one of those extensions that's a bit confusing to setup, but it's not very dificult if you know how to.
## First: OPcache should be installed
To begin with, we expect to have OPcache installed on your server. This is a PHP extension and on a Ubuntu server you can install it using (change to your current PHP version accordingly):
```bash
sudo apt install php8.4-opcache
```
## How to enable JIT compiling
By default JIT is disabled in OPcache, so you need to enable it manually. Also note that the enabling of OPcache is separate for the PHP (FPM) process and using PHP on the CLI.
JIT can be enabled by setting `opcache.jit_buffer_size` to a value and in general `128M` is a pretty decent value that's enough for most PHP applications. Add this line:
```ini
opcache.jit_buffer_size=128M
```
To your `php.ini` config file or in `/etc/php/8.4/mods-available/opcache.ini`:
```bash
opcache.enabled=1
opcache.jit_buffer_size=128M
```
Now JIT is enabled!
## Optimization level
Next you need to decide a proper `opcache.jit` value that determines what optimizations the JIT compiler will perform. This can be precisely set as a bitmasker using a 4-digit integer "CRTO". More on this in the [PHP docs](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.jit).
But for typical usage, it's easier to use the following presets that cover most use cases. The following options:
```php
# Completely disabled, cannot be enabled at runtime.
opcache.jit=disable
# Disabled, but can be enabled at runtime.
opcache.jit=off
# Use tracing JIT. Enabled by default and recommended for most users.
opcache.jit=on # or opcache.jit=tracing
# Use function JIT.
opcache.jit=function
```
For general usage you can choose `on` which is almost the highest setting (1254) to enable almost all optimization levels. This makes the config now:
```bash
opcache.enable=1
opcache.jit=on
opcache.jit_buffer_size=128M
```
## Enable in the CLI
If you also want to optimize CLI usage, you can enable OPcache (and JIT) by adding:
```bash
opcache.enable_cli=1
```
### How to install multiple PHP versions on the same server
Source: https://rocketeersapp.com/how-to-install-multiple-php-versions-on-same-server
One server, several sites, different PHP versions. Here is how to install PHP 8.2, 8.3, and 8.4 side by side and point each site at the version it needs.
Not every site upgrades on the same schedule. One app needs PHP 8.4, an older one is stuck on 8.2, and they both live on the same server. The good news: PHP-FPM runs a separate service and socket per version, so you can install as many as you like and route each site to the right one.
## Install the versions you need
With the [ondrej/php PPA](/how-to-install-php) added, install each version's FPM and CLI packages. Just repeat the install for every version:
```bash
sudo add-apt-repository ppa:ondrej/php -y
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
php8.2-fpm php8.2-cli php8.2-mysql php8.2-mbstring php8.2-xml php8.2-curl \
php8.3-fpm php8.3-cli php8.3-mysql php8.3-mbstring php8.3-xml php8.3-curl \
php8.4-fpm php8.4-cli php8.4-mysql php8.4-mbstring php8.4-xml php8.4-curl
```
Each version installs its own FPM service and its own socket:
```bash
sudo systemctl status php8.2-fpm php8.3-fpm php8.4-fpm
```
```bash
/var/run/php/php8.2-fpm.sock
/var/run/php/php8.3-fpm.sock
/var/run/php/php8.4-fpm.sock
```
## Point each site at a version
This is the key step: in each site's [Nginx server block](/how-to-install-nginx), set `fastcgi_pass` to the socket of the version that site should use.
A site on PHP 8.4:
```nginx
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
```
A different site on PHP 8.2 — same block, different socket:
```nginx
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
```
Reload Nginx after editing, and each site runs on its own version simultaneously:
```bash
sudo nginx -t && sudo service nginx reload
```
## Set the CLI default
The command line `php` is separate from what your sites use over FPM. Pick which version `php` resolves to on the command line with `update-alternatives`:
```bash
sudo update-alternatives --set php /usr/bin/php8.4
```
Check it:
```bash
php -v
```
You can still call any version explicitly — `php8.2 artisan migrate`, `php8.3 -v` — regardless of the default.
## Turn off the ones you don't use
Every running FPM pool holds memory. If a version is installed but no site uses it, stop and disable its service so it isn't sitting idle — see [disabling unused PHP-FPM pools](/disable-unused-php-fpm-pools).
Developing locally on a Mac? [Laravel Valet can switch PHP versions](/different-php-versions-laravel-valet) the same way for your local sites.
## Let Rocketeers handle it
Installing versions, remembering which socket maps to which site, keeping the CLI default straight, and shutting down idle pools is exactly the kind of bookkeeping that drifts out of sync over time. Rocketeers installs every PHP version you need on a server and lets each site [choose its version with a click](/features/multiple-php-versions-per-server) — the FPM socket wiring and the cleanup happen for you.
### How to install PHP on Ubuntu
Source: https://rocketeersapp.com/how-to-install-php
A modern PHP install means more than one apt package — you need PHP-FPM and the right set of extensions for your application. Here is how to install PHP on Ubuntu and wire it into Nginx.
Ubuntu's default repositories are usually a PHP version or two behind, and they don't always carry every extension you'll need. The standard fix is Ondřej Surý's PPA, which packages every current PHP release for Ubuntu and keeps them up to date. Here's how to install PHP properly for a web server.
## Add the PHP repository
Add the PPA that almost every production PHP server uses:
```bash
sudo add-apt-repository ppa:ondrej/php -y
sudo apt-get update
```
## Install PHP-FPM and the extensions you need
For a web server you want PHP-FPM (the FastCGI process manager [Nginx](/how-to-install-nginx) talks to), the CLI, and the common extensions. This installs PHP 8.4 with the set a typical Laravel or modern PHP app expects:
```bash
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
php8.4-fpm php8.4-cli php8.4-curl php8.4-mbstring php8.4-xml php8.4-zip \
php8.4-mysql php8.4-pgsql php8.4-sqlite3 php8.4-gd php8.4-intl php8.4-bcmath \
php8.4-redis php8.4-opcache php8.4-readline
```
Swap `8.4` for whichever version you need — `8.3`, `8.2`, and so on are all available from the same PPA.
## Verify the install
Check the CLI version and that the FastCGI service is running:
```bash
php -v
sudo systemctl status php8.4-fpm
```
PHP-FPM listens on a Unix socket at `/var/run/php/php8.4-fpm.sock`. That's the path your Nginx server block passes requests to with `fastcgi_pass`.
## Connect PHP to Nginx
In your site's server block, hand `.php` files to the FPM socket:
```nginx
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
```
Test and reload Nginx, then drop a `phpinfo()` file in your web root to confirm PHP is executing:
```bash
sudo nginx -t && sudo service nginx reload
```
If you see the raw PHP source instead of a rendered page, the `location ~ \.php$` block isn't matching — that's the usual cause of "PHP code showing in the browser."
## Tune it for production
The default `php.ini` is conservative. Most sites need a higher [memory limit](/increase-php-memory-limit), realistic [upload limits](/php-file-upload-exceeds-upload-max-filesize), and [OPcache enabled](/enable-opcache-php) for speed. We walk through the settings that matter in [important PHP config options](/important-php-config-options).
## Install Composer
Most PHP applications need [Composer](https://getcomposer.org) for dependencies:
```bash
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
```
## Run more than one version
Different sites often need different PHP versions on the same box. That's entirely possible — see [how to install multiple PHP versions on the same server](/how-to-install-multiple-php-versions-on-same-server). And once it's installed, here's [how to run PHP files](/run-php-files).
## Let Rocketeers handle it
Picking the right extension set, keeping FPM tuned, enabling OPcache, installing Composer, and doing it again for every PHP version your sites need is fiddly, repetitive work. Rocketeers provisions PHP with the full production extension set, tunes `php.ini` and FPM sensibly, and lets each site pick its own [PHP version](/features/multiple-php-versions-per-server) — no PPAs or socket paths to remember.
### Important PHP config options
Source: https://rocketeersapp.com/important-php-config-options
The default php.ini is built to be safe, not to run a real website. These are the settings that actually matter in production and what to set them to.
After you [install PHP](/how-to-install-php), it works — but the defaults in `php.ini` are conservative placeholders, not production values. A handful of settings make the difference between a site that runs smoothly and one that throws cryptic errors under load. Here are the ones worth knowing.
## Find your php.ini
There's a separate `php.ini` per version and per SAPI (CLI vs FPM). Ask PHP where each one lives:
```bash
php --ini
```
For a web server, the file you care about is the FPM one, usually `/etc/php/8.4/fpm/php.ini`. Restart FPM after any change:
```bash
sudo service php8.4-fpm restart
```
## memory_limit
How much memory a single script may use. The default of `128M` is tight for modern frameworks and image processing. `256M` or `512M` is a common production value:
```ini
memory_limit = 512M
```
Set it too low and you get [allowed memory size exhausted](/php-allowed-memory-size-exhausted). More detail in [increase PHP memory limit](/increase-php-memory-limit).
## max_execution_time
The longest a script may run before PHP kills it. The default `30` seconds is fine for web requests — keeping it low protects you from runaway scripts. Raise it only for specific long jobs, and prefer queues over long requests:
```ini
max_execution_time = 30
```
Hitting the limit produces [maximum execution time exceeded](/php-maximum-execution-time-exceeded).
## upload_max_filesize and post_max_size
These cap file uploads, and they work together — `post_max_size` must be at least as large as `upload_max_filesize`, because the upload travels inside the POST body:
```ini
upload_max_filesize = 64M
post_max_size = 64M
```
If uploads fail silently, this pair is almost always why — see [POST file exceeds upload_max_filesize](/php-file-upload-exceeds-upload-max-filesize).
## error_reporting and display_errors
This is the setting people get backwards most often. On a **production** site, never show errors to visitors — log them instead:
```ini
display_errors = Off
log_errors = On
error_reporting = E_ALL
```
On a **development** machine you want the opposite, so you actually see what broke:
```ini
display_errors = On
```
Leaking a stack trace to the public is both ugly and a security risk. Keep it off in production.
## date.timezone
An unset timezone makes PHP guess and emit warnings. Set it explicitly — UTC is the safe default for servers:
```ini
date.timezone = UTC
```
## opcache
OPcache compiles your PHP to bytecode and keeps it in memory, which is one of the biggest free performance wins available. Make sure it's on:
```ini
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
```
Full walkthrough in [enabling OPcache](/enable-opcache-php), and on PHP 8 you can go further with [the OPcache JIT](/php-8-opcache-jit).
## FPM self-healing
In `php-fpm.conf`, these let the FPM master restart workers automatically if they start crashing, instead of taking the pool down with them:
```ini
emergency_restart_threshold = 10
emergency_restart_interval = 1m
process_control_timeout = 30s
```
## Keep secrets out of php.ini
Configuration that changes between environments — database credentials, API keys — belongs in environment variables, not `php.ini` or your code. See [environment variables in Laravel](/environment-variables-laravel) for the pattern.
## Let Rocketeers handle it
Every one of these has a sensible production value, a separate file per PHP version, and an FPM restart to make it stick — and [getting `display_errors` wrong on production](/features/turn-off-debug-mode-automatically-on-production) is a real security hole. Rocketeers tunes `php.ini` and FPM to production-ready values for every PHP version it installs, so the defaults you start with are already the right ones.
### PHP Warning: Undefined array key
Source: https://rocketeersapp.com/undefined-array-key-php
PHP 8 turned accessing a missing array key into a warning instead of a silent notice. The fix is to check the key exists or provide a default before reading it.
## About the error
```bash
PHP Warning: Undefined array key "email" in /var/www/app.php on line 12
```
You read `$array['email']` but that key doesn't exist. In PHP 7 this was a quiet `E_NOTICE` many people never saw; **PHP 8 promoted it to an `E_WARNING`**, so upgrading a codebase surfaces a flood of these. A closely related message is `Trying to access array offset on value of type null`, which means the thing you indexed wasn't an array at all.
## Why do I see this error
- The key really isn't there (an optional form field, a missing query parameter).
- A typo in the key name.
- The variable is `null` rather than an array, so there's no key to read.
- Code that "worked" on PHP 7 because the notice was hidden.
## Solution
### Provide a default with the null coalescing operator
The cleanest fix is `??`, which returns the right-hand side when the key is missing or null:
```php
$email = $_POST['email'] ?? null;
$page = $_GET['page'] ?? 1;
```
### Check existence explicitly
When you need to branch on whether the key is present:
```php
if (array_key_exists('email', $data)) {
// present, even if its value is null
}
if (isset($data['email'])) {
// present and not null
}
```
Note the difference: `isset()` treats a key with a `null` value as "not set", while `array_key_exists()` only checks the key is there.
### In Laravel
Use `data_get()` or request helpers, which return `null` (or a default) instead of warning:
```php
$value = data_get($array, 'user.email', 'unknown');
$page = $request->input('page', 1);
```
If the underlying value is an object rather than an array and you're calling a method on it, see [Call to a member function on null](/call-to-a-member-function-on-null). And if PHP can't find a class at all, see [Class not found](/php-class-not-found).
### PHP file upload exceeds upload_max_filesize
Source: https://rocketeersapp.com/php-file-upload-exceeds-upload-max-filesize
When a file upload silently fails or is empty, PHP itself is usually rejecting it because it is larger than upload_max_filesize or post_max_size. This is the PHP-side companion to the nginx 413 error.
## About the error
Unlike most errors, this one is quiet. The upload just doesn't arrive, `$_FILES` is empty, or you see `UPLOAD_ERR_INI_SIZE` in the file's `error` key. PHP discarded the file before your code ever ran.
This is distinct from, but often confused with, the [413 Request Entity Too Large](/413-request-entity-too-large) error. That one is nginx rejecting the request before it reaches PHP. If you've already raised nginx's `client_max_body_size` and uploads still fail, PHP's own limits are the next place to look.
## Why do I see this error
Two PHP settings cap upload size, and the *lower* of the two wins:
- `upload_max_filesize`, the maximum size of a single uploaded file.
- `post_max_size`, the maximum size of the entire POST body (which must be larger than `upload_max_filesize`, with room for other fields).
If either is smaller than the file, PHP drops it.
## Solution
Raise both values in your `php.ini`. `post_max_size` should be a bit larger than `upload_max_filesize`:
```ini
upload_max_filesize = 100M
post_max_size = 110M
```
While you're there, check these related limits don't cut a large upload short:
```ini
max_file_uploads = 20
max_execution_time = 120
memory_limit = 256M
```
Reload PHP-FPM so the changes take effect:
```bash
systemctl reload php8.3-fpm
```
### Don't forget the layers in front of PHP
A large upload has to pass through every layer to succeed. All of these must be big enough:
- **nginx**, via `client_max_body_size`, see [413 Request Entity Too Large](/413-request-entity-too-large).
- **PHP**, via `upload_max_filesize` and `post_max_size` above.
- **Your application's own validation**, for example a Laravel `max:` rule on the file.
Verify the active PHP values with:
```bash
php -i | grep -E 'upload_max_filesize|post_max_size'
```
Note that the CLI and the FPM SAPI can use different `php.ini` files, so check the FPM one (`/etc/php/8.3/fpm/php.ini`) for web uploads.
### PHP Maximum execution time of N seconds exceeded
Source: https://rocketeersapp.com/php-maximum-execution-time-exceeded
This fatal error means a PHP script ran longer than the allowed time limit and was killed. Raise the limit for legitimately long work, but the real fix is usually to make the script faster or move it to a queue.
## About the error
```bash
PHP Fatal error: Maximum execution time of 30 seconds exceeded
```
PHP caps how long a single script may run with the `max_execution_time` setting (default 30 seconds for web requests). When a script exceeds it, PHP terminates it mid-run, which usually surfaces as a [500 error](/500-internal-server-error-laravel) or a half-rendered page.
## Why do I see this error
- A slow database query, often a missing index.
- Heavy work done inside a web request: large exports, image processing, sending many emails.
- A slow or hanging external API call.
- An accidental infinite loop.
Note that `max_execution_time` counts CPU time of the script itself, not time spent waiting on the database or external calls on all platforms, but on a typical web request the practical effect is a hard ceiling on how long the page can take.
## Solution
### Raise the limit
In `php.ini`:
```ini
max_execution_time = 120
```
For a single CLI command without touching config:
```bash
php -d max_execution_time=300 artisan some:command
```
Reload PHP-FPM after editing `php.ini`:
```bash
systemctl reload php8.3-fpm
```
Remember nginx has its own timeout in front of PHP, if it's lower, nginx gives up first with a [504 Gateway Timeout](/504-gateway-timeout-nginx). Both need to be raised together.
### The real fix: don't do slow work in a request
CLI scripts (like `artisan` commands) default to no time limit, so the right home for slow work is a background queue, not the request cycle. In Laravel, dispatch it:
```php
ProcessExport::dispatch($user);
```
The request returns instantly and the heavy lifting happens on a worker. Combine that with adding the missing database index and the timeout disappears for good. If the script is dying on memory rather than time, see [PHP allowed memory size exhausted](/php-allowed-memory-size-exhausted).
### PHP Fatal error: Allowed memory size exhausted
Source: https://rocketeersapp.com/php-allowed-memory-size-exhausted
This fatal error means a PHP script tried to use more memory than the configured limit. Raise the limit, but first ask why the script needs that much.
## About the error
The full message looks like this:
```bash
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes)
```
The number `134217728` is the limit in bytes, in this case 128 MB. PHP refused to give the script any more memory and stopped it. Because it is a fatal error, it usually surfaces as a blank page or a [500 error](/500-internal-server-error-laravel) in the browser.
## Why do I see this error
PHP caps how much memory a single script may use through the `memory_limit` setting. A script hits that cap for one of two reasons:
- It genuinely needs more, for example exporting a large dataset or processing a big file.
- It has a problem: loading thousands of Eloquent models at once, an accidental infinite loop, or building a huge array in memory.
The amount it "tried to allocate" is a hint. A tiny allocation (like 20 KB above) failing means the script was already sitting right at the limit.
## Solution
To raise the limit globally, edit `memory_limit` in your `php.ini`:
```ini
memory_limit = 256M
```
For a single CLI command without touching config:
```bash
php -d memory_limit=512M artisan some:command
```
And to set it from inside a script at runtime (use sparingly):
```php
ini_set('memory_limit', '512M');
```
After editing `php.ini`, reload PHP-FPM so the change takes effect:
```bash
systemctl reload php8.3-fpm
```
Before you reach for a bigger number, check whether the code is the real culprit. In Laravel, the classic cause is loading everything at once:
```php
// Loads every row into memory at once
$users = User::all();
// Streams rows in small batches instead
User::chunk(500, function ($users) {
// ...
});
```
Use `chunk()`, `cursor()`, or `lazy()` for large result sets. Setting `memory_limit = -1` (unlimited) hides the problem and risks taking the whole server down, so avoid it in production.
### Composer out of memory (allowed memory size exhausted)
Source: https://rocketeersapp.com/composer-out-of-memory-allowed-memory-size-exhausted
Composer can run out of memory while resolving dependencies, especially on small VPSes. The real fix is usually swap space, not a bigger memory limit.
## About the error
Running `composer update` or `composer require` fails with:
```bash
PHP Fatal error: Allowed memory size of 1610612736 bytes exhausted (tried to allocate ...)
```
Dependency resolution is memory-hungry. To pick compatible versions, Composer compares every candidate version of every package against all the others, and large or conflicted dependency trees can exhaust the available memory.
## Why do I see this error
- A big dependency tree, or `composer update` resolving everything from scratch.
- A small server with little RAM and **no swap** configured.
- An old Composer 1.x install, version 1 used far more memory than version 2.
## Solution
### Make sure you're on Composer 2
Composer 2 cut memory use dramatically. Check and upgrade:
```bash
composer --version
composer self-update --2
```
### Add swap space (the proper fix on a small VPS)
If `proc_open(): fork failed` or the out-of-memory error shows up on a low-RAM server, the machine simply has no headroom. Adding swap solves it durably without raising PHP limits. See the dedicated guide on [adding swap space on Ubuntu](/add-swap-space-on-ubuntu).
### Raise the memory limit for one command
To get unblocked immediately, run Composer with an unlimited limit just for that invocation:
```bash
COMPOSER_MEMORY_LIMIT=-1 composer update
```
Or point Composer at a php.ini with a higher limit:
```bash
php -d memory_limit=-1 /usr/local/bin/composer update
```
### Prefer require over update where you can
`composer require some/package` resolves a smaller slice of the tree than a full `composer update`, and `composer install` against an existing `composer.lock` barely uses any memory at all. On production you should only ever run `composer install`, never `update`:
```bash
composer install --no-dev --optimize-autoloader
```
For installing local packages during development, see [installing Composer packages locally](/install-composer-packages-locally). The same memory ceiling shows up in [increase PHP memory limit](/increase-php-memory-limit).
### PHP Fatal error: Class "X" not found
Source: https://rocketeersapp.com/php-class-not-found
This fatal error means PHP could not load a class you referenced. Almost always a namespace mismatch, a wrong file name, or an autoloader that needs regenerating.
## About the error
```bash
PHP Fatal error: Uncaught Error: Class "App\Services\Invoice" not found
```
PHP tried to use a class and couldn't find a definition for it. With Composer's PSR-4 autoloading, the class name and namespace must map exactly to a file on disk, and any mismatch breaks that lookup.
## Why do I see this error
- The **namespace** declared in the file doesn't match its folder.
- The **file name** doesn't match the class name (PSR-4 is case-sensitive, and so is Linux).
- You forgot a `use` statement, so PHP looks for the class in the current namespace.
- The class is new and the autoloader hasn't been regenerated.
- A typo in the class name.
## Solution
### Check the namespace matches the path
Under PSR-4, `App\Services\Invoice` must live at `app/Services/Invoice.php` and declare:
```php
namespace App\Services;
class Invoice
{
// ...
}
```
The file name (`Invoice.php`) must match the class name exactly, including case. This works on macOS (case-insensitive filesystem) and then breaks on a Linux server, a very common "works locally, 500 in production" trap.
### Import the class
If the class is in another namespace, add a `use` statement at the top of the file:
```php
use App\Services\Invoice;
$invoice = new Invoice();
```
### Regenerate the autoloader
If the class genuinely exists and is named correctly, Composer's autoload map may be stale:
```bash
composer dump-autoload
```
For framework classes resolved through the container (controllers, etc.), the symptom is slightly different, see [Target class does not exist in Laravel](/target-class-does-not-exist-laravel).
### PHP: Call to a member function on null
Source: https://rocketeersapp.com/call-to-a-member-function-on-null
This error means you called a method on a variable that turned out to be null. The variable you expected to hold an object was empty, often a database lookup that found nothing.
## About the error
```bash
Error: Call to a member function format() on null
```
You called a method (`->format()`, `->name`, `->save()`) on something that was `null` instead of the object you expected. PHP can't call a method on nothing, so it throws. In a web request this reaches visitors as a [500 Internal Server Error](/500-internal-server-error-laravel).
The method name in the message is a strong clue: it tells you *which* object was missing.
## Why do I see this error
- A database query returned no row, so the model is `null`.
- A relationship isn't loaded or doesn't exist for this record.
- An optional value (a nullable column, a missing config key) is genuinely empty.
- A function you assumed always returns an object returned `null` on failure.
## Solution
### Find the null
In Laravel, `find()` and `first()` return `null` when nothing matches. Calling a method on that result fails:
```php
$user = User::find($id); // null if no such user
echo $user->name; // Call to a member function on null
```
### Handle the empty case
Decide what should happen when it's missing. To 404 automatically, use `findOrFail()`:
```php
$user = User::findOrFail($id); // throws a 404 instead of returning null
```
To provide a fallback, check first or use the nullsafe operator (PHP 8+):
```php
// guard explicitly
if ($user) {
echo $user->name;
}
// or the nullsafe operator: returns null instead of erroring
echo $user?->profile?->bio ?? 'No bio';
```
### Relationships
A relationship that hasn't been set returns `null` too. The nullsafe operator and `optional()` helper both guard against it:
```php
$company = $user->company?->name ?? 'Independent';
```
The fix is rarely to suppress the error, it's to decide deliberately what the empty case should do. A related strictness error is the [undefined array key](/undefined-array-key-php) warning in PHP 8.
### How to run PHP files
Source: https://rocketeersapp.com/run-php-files
You cannot execute PHP files directly on your computer without any additional tools. You need a (local) webserver that can run the PHP files.
## PHP is a Server-side Language
PHP is a language that runs on the server-side, that means it is normally be executed by a (web) server which can return the response that PHP generates. Typically PHP returns HTML, but it can output any type of format or file, like JSON or even images or video. Running it on the server makes PHP also a back-end language.
For comparison a great example of a language that is originally not server-side, is Javascript. This is a client-side language that can be used directly in your browser just like HTML, CSS and the likes. It also means that Javascript, CSS and HTML are front-end languages.
## How to run PHP files locally
When you want to use PHP locally and in the browser, you actually need a web server running locally on your computer. This way you can use PHP the way it is used most of the time: to build a website or web application.
For Windows and macOS users the [XAMPP](https://www.apachefriends.org/) project is still very popular for beginners to easily spin a local web environment to run your PHP application locally using the browser.
For macOS users specifically it is highly recommended to use [Laravel Valet](https://laravel.com/docs/valet). This is an CLI tool that installs all the tools you need locally and run your Laravel applications, but even any other type of website or applications like WordPress or Magento. It is a really powerful tool that works by leveraging the power of [Homebrew](https://brew.sh/).
## Command Line
When you have PHP installed on your computer or server, you can also use it to execute PHP files using the command line. This is handy when you don't need to output anything visual like a HTML design or graphic content like an image or video.
To execute a PHP file you have written, you can run it using this command:
```bash
php -r /path/to/php/file
```
When you just want to execute a short command, you do not need to create a separate file for it, you can just run it directly in the command line using the `-r` parameter:
```bash
php -r "echo time();"
```
To set PHP up first, see [how to install PHP](/how-to-install-php); for switching versions locally, see [different PHP versions with Laravel Valet](/different-php-versions-laravel-valet).
## Performance
### Tools to validate your website
Source: https://rocketeersapp.com/tools-to-validate-website
Before and after you launch, a handful of free tools tell you whether your site is secure, fast, and correctly configured. Here are the ones worth running.
You can't fix what you can't see. After you've [set up a server](/how-to-host-your-own-website) and put a site live, these free tools grade the things that matter — TLS, security headers, performance, and markup — and tell you exactly what to improve. Run them at launch, then again whenever you make a significant change.
## SSL and TLS
### SSL Labs Server Test
The gold standard for inspecting your HTTPS configuration. It grades your TLS setup from A+ down to F and flags weak protocols, missing chain certificates, and poor cipher choices.
- **[SSL Labs](https://www.ssllabs.com/ssltest/)** — aim for an A+; you can also run our own [SSL checker](/ssl); here's [how to get there with Cloudflare](/a-plus-grade-ssl-using-cloudflare).
A common failure it surfaces is an [incomplete certificate chain](/what-is-an-ssl-certificate-chain). You can also [check expiry yourself from the command line](/check-ssl-certificate-expiration).
## Security headers
### Security Headers and Mozilla Observatory
These scan the HTTP response headers your site sends and grade them — whether you set `Strict-Transport-Security`, a Content Security Policy, `X-Content-Type-Options`, and so on.
- **[securityheaders.com](https://securityheaders.com)** — quick header grade (or use our [security headers checker](/security-headers)).
- **[Mozilla Observatory](https://observatory.mozilla.org)** — a broader security scan.
More context in [optimizing web application security](/optimize-web-application-security).
## Performance
### PageSpeed Insights, Lighthouse, and WebPageTest
These measure how fast your site loads for real users and give specific, prioritized fixes — render-blocking resources, uncompressed assets, slow server response.
- **[PageSpeed Insights](https://pagespeed.web.dev)** — Google's Core Web Vitals report.
- **Lighthouse** — built into Chrome DevTools, under the "Lighthouse" tab.
- **[WebPageTest](https://www.webpagetest.org)** — detailed waterfall from multiple locations.
If your server response is the bottleneck, it'll show up as a slow [time to first byte](/measure-ttfb). General wins are in [optimizing website performance](/optimize-website-performance), and on the server side make sure you've [enabled gzip compression](/enable-gzip-compression-nginx).
## Markup and standards
### W3C validators
Catch broken HTML and invalid CSS that can cause subtle rendering and accessibility issues.
- **[W3C Markup Validator](https://validator.w3.org)** — HTML.
- **[W3C CSS Validator](https://jigsaw.w3.org/css-validator/)** — CSS.
## DNS and email
### DNS and deliverability checkers
If you send email from your domain, verify your SPF, DKIM, and DMARC records so your mail isn't flagged as spam.
- **[MXToolbox](https://mxtoolbox.com)** — DNS, blacklist, and email record lookups.
See [improving email deliverability](/improving-email-deliverability) for what those records should contain.
## Turn results into a launch list
Running the tools is step one; acting on them is step two. Pair this with the [website launch checklist](/website-checklist) so nothing slips through before you go live.
## Let Rocketeers handle it
Most of what these tools flag — weak TLS, missing security headers, no compression, an expired certificate — comes down to server configuration. Rocketeers provisions servers and sites with strong TLS, sensible headers, and compression on by default, and [monitors your sites](/features/monitor-your-sites-and-servers) after launch, so you spend your time fixing the few things left rather than the basics.
### How to find and optimize slow MySQL queries
Source: https://rocketeersapp.com/mysql-query-optimization
A repeatable process for MySQL query optimization, find slow MySQL queries with the slow query log, diagnose them with EXPLAIN, and fix them with the right indexes and query rewrites.
When a request is slow, the database is usually to blame, and the database is usually slow because of a missing index or a query written in a way the optimizer can't use. MySQL query optimization isn't guesswork: there's a repeatable loop. Find the slow queries with the slow query log, diagnose each one with `EXPLAIN`, then fix it with an index or a rewrite. This guide walks through that loop end to end.
## Find slow queries with the slow query log
You can't optimize slow MySQL queries until you know which ones are slow. The slow query log records every statement that takes longer than a threshold you set.
Check the current settings:
```sql
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
```
Enable it at runtime (no restart needed) and log anything over half a second:
```sql
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
```
To make it survive a restart, set the same values in `my.cnf` (usually `/etc/mysql/mysql.conf.d/mysqld.cnf`):
```ini
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.5
log_queries_not_using_indexes = 1
```
`log_queries_not_using_indexes` is worth turning on temporarily, it catches full table scans even when they're currently fast, before the table grows.
The raw log is noisy. Use `mysqldumpslow` to aggregate it, so you see the worst offenders first instead of one line per execution:
```bash
# Top 10 queries by total time spent
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
```
`-s t` sorts by total time (the queries actually costing you the most), `-t 10` limits to ten results. Start at the top of that list.
## Diagnose with EXPLAIN
Once you have a slow query, put `EXPLAIN` in front of it to see how MySQL plans to run it:
```sql
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
```
Three columns tell you almost everything:
- **`type`** — the access method. `ALL` means a **full table scan**, every row read, and is the clearest sign of trouble. `ref`, `eq_ref`, `range`, or `const` mean an index is doing the work.
- **`key`** — the index MySQL chose. `NULL` means no index is being used for this query.
- **`rows`** — the estimated number of rows MySQL expects to examine. A number close to the table's total row count confirms a scan.
So `type = ALL`, `key = NULL`, and a large `rows` estimate together mean the query is scanning the whole table. That's the query to fix next.
## Add the right index
Most of the time, the fix for a scanning query is an index on the columns in the `WHERE` clause. For the query above, a composite index covering both filtered columns lets MySQL jump straight to the matching rows:
```sql
ALTER TABLE orders ADD INDEX idx_orders_customer_status (customer_id, status);
```
Re-run `EXPLAIN` and confirm `type` is now `ref` and `key` shows your new index. Don't index blindly, though: indexes slow down writes and the column order matters. For when to add a composite index, the leftmost-prefix rule, and the write cost, see the full walkthrough in [how database indexing works](/database-indexing).
## Rewrite index-unfriendly queries
An index only helps if the query is written so MySQL can use it. These rewrites fix the most common cases where `key` stays `NULL` even after you add an index.
**Don't `SELECT *`.** Selecting only the columns you need keeps result sets small and can let a covering index satisfy the query without touching the table at all:
```sql
-- Reads every column, can't be covered by an index
SELECT * FROM orders WHERE customer_id = 42;
-- Only what you need
SELECT id, total, created_at FROM orders WHERE customer_id = 42;
```
**Don't wrap an indexed column in a function.** The index on `created_at` is useless here because MySQL has to compute `DATE()` for every row:
```sql
-- Can't use the index
SELECT id FROM orders WHERE DATE(created_at) = '2026-06-23';
-- Range condition, uses the index
SELECT id FROM orders
WHERE created_at >= '2026-06-23' AND created_at < '2026-06-24';
```
**Avoid a leading wildcard in `LIKE`.** `'%smith'` forces a scan; `'smith%'` can use the index because the prefix is fixed:
```sql
SELECT id FROM customers WHERE name LIKE 'smith%';
```
**Prefer range conditions and always limit large result sets.** Returning 100,000 rows to the application is slow regardless of indexing, page the results:
```sql
SELECT id, total FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 50;
```
## EXPLAIN ANALYZE on MySQL 8.0
`EXPLAIN` shows the optimizer's *plan*. On MySQL 8.0, `EXPLAIN ANALYZE` actually *runs* the query and reports real timings per step, so you can see where the time genuinely goes rather than relying on estimates:
```sql
EXPLAIN ANALYZE SELECT id, total FROM orders WHERE customer_id = 42;
```
If you're still on 5.7 you won't have this, and you're missing optimizer improvements and better index limits too, see [upgrading MySQL 5.7 to 8.0 on Ubuntu](/upgrade-mysql-5-7-to-8-0-ubuntu). Server-wide tuning (buffer pool size, connection limits) is a separate lever covered in [optimizing MySQL performance](/optimize-mysql-performance). And if a query won't run at all, the cause is often a [1064 SQL syntax error](/mysql-1064-sql-syntax-error).
## Conclusion
MySQL query optimization is a loop, not a one-time fix: enable the slow query log to find the worst queries, run `EXPLAIN` to see why they're slow, then add a focused index or rewrite the query so the index can be used. Confirm every change with `EXPLAIN` before moving on, and keep watching the slow log as your data grows, the query that's fast today on ten thousand rows is the full table scan that pages you at ten million.
### How to clear the Redis cache
Source: https://rocketeersapp.com/clear-redis-cache
A practical guide to clear the Redis cache, from flushing a single database with redis-cli to safely deleting keys by pattern and clearing the cache from Laravel.
You usually clear the Redis cache after a deploy, when stale data is being served, or while debugging locally. The commands are simple, but on a shared or production instance they are destructive: a single flush wipes every cached value at once. This guide covers how to clear the Redis cache safely, from a full flush down to deleting individual keys.
## FLUSHDB vs FLUSHALL
Redis splits its keyspace into numbered databases (0 by default). The two flush commands differ in scope:
- **`FLUSHDB`** clears the *current* database only.
- **`FLUSHALL`** clears *every* database on the instance.
Connect with `redis-cli` and pick a database with `-n`:
```bash
# Clear database 0 (the default)
redis-cli FLUSHDB
# Connect to database 2, then clear it
redis-cli -n 2 FLUSHDB
# Wipe every database on the instance
redis-cli FLUSHALL
```
Inside an interactive session you select the database with `SELECT`:
```bash
redis-cli
127.0.0.1:6379> SELECT 2
OK
127.0.0.1:6379[2]> FLUSHDB
OK
```
If `redis-cli` itself can't connect, that's a separate problem, see [Redis connection refused in Laravel](/redis-connection-refused-laravel).
## Clear specific keys
Flushing is rarely what you want in production. To remove one key, use `DEL`:
```bash
redis-cli DEL session:abc123
redis-cli DEL user:42 user:43 user:44
```
To delete everything matching a pattern, you might reach for `KEYS`, **don't**. `KEYS` scans the entire keyspace in a single blocking operation; on a large dataset it freezes the server for the duration. Use `SCAN`, which iterates in small batches, and pipe the results into `DEL`:
```bash
redis-cli --scan --pattern 'session:*' | xargs -L 100 redis-cli DEL
```
`--scan` cursors through the keyspace without blocking, and `-L 100` deletes in chunks of 100 keys so you don't build one enormous command. This is the safe way to clear the Redis cache for a subset of keys on a live instance.
## Async (non-blocking) flush
`FLUSHDB` and `FLUSHALL` are synchronous by default: on a cache holding millions of keys, freeing that memory blocks the server. The `ASYNC` modifier hands the cleanup to a background thread so the command returns immediately:
```bash
redis-cli FLUSHDB ASYNC
redis-cli FLUSHALL ASYNC
```
Use `ASYNC` on any large production cache. The keys disappear right away; only the memory reclamation happens in the background.
## Clearing the cache from Laravel
If Redis is your Laravel cache store, you rarely touch `redis-cli` at all. The Artisan command clears the default store:
```bash
php artisan cache:clear
```
The programmatic equivalent is `Cache::flush()`:
```php
use Illuminate\Support\Facades\Cache;
Cache::flush();
```
To clear a specific store instead of the default, name it:
```php
Cache::store('redis')->flush();
```
```bash
php artisan cache:clear --store=redis
```
One caveat: `flush()` clears the *entire* store, including any non-cache data sharing that Redis database (sessions, queues). If you only want to drop tagged entries, flush by tag:
```php
Cache::tags(['users'])->flush();
```
For the full set of Laravel options see [clearing the cache in Laravel](/clear-cache-laravel) and the [Laravel cache](/laravel-cache) reference.
## A word of caution in production
Clearing the cache in production isn't free. When you flush, every subsequent request misses the cache at once and falls through to the database. That sudden surge, a **cache stampede** or **thundering herd**, can overload the database hard enough to take the site down, exactly when you were trying to fix it.
To soften the impact:
- Clear specific keys with `SCAN` + `DEL` instead of a full flush whenever you can.
- Warm critical keys right after flushing.
- Use `FLUSHALL ASYNC` so the flush itself doesn't block Redis.
## Conclusion
To clear the Redis cache, match the tool to the blast radius: `DEL` or `SCAN` for individual keys, `FLUSHDB` for one database, `FLUSHALL` for the whole instance, and `ASYNC` on anything large. From Laravel, prefer `php artisan cache:clear` or `Cache::flush()`. In production, lean toward targeted deletes and be ready for the cache stampede a full flush can trigger.
### A complete guide to caching in Laravel
Source: https://rocketeersapp.com/laravel-cache
The Laravel cache gives you one API in front of file, Redis, database, and memcached stores. Here is how to configure drivers, cache slow queries, invalidate by tag, and clear it all.
Caching is the cheapest performance win you have after indexing. The Laravel cache wraps file, database, Redis, and memcached behind a single `Cache` facade, so you write the same code regardless of where the data lives. This guide covers the drivers, the day-to-day API, cache tags, and how application caching differs from data caching.
## What the Laravel cache is for
Laravel caching stores the result of expensive work, a slow query, an API call, a rendered fragment, so you compute it once and serve it from fast storage afterwards. Everything goes through the unified `Illuminate\Support\Facades\Cache` facade, which talks to whichever **store** you've configured. Swap the store from `file` to `redis` and not a line of your application code changes.
## Cache drivers
Laravel ships with several drivers, configured in `config/cache.php` and selected per environment in `.env`:
- **`file`** — serializes values to `storage/framework/cache`. Zero setup, fine for a single small server. Slow and not shared across machines.
- **`database`** — stores cache rows in a table. Survives deploys, works across servers, but adds load to the DB you're usually trying to protect.
- **`redis`** — in-memory, fast, supports tags and atomic locks. The default choice for production.
- **`memcached`** — also in-memory and fast; supports tags but lacks Redis's richer data types and persistence.
- **`array`** — keeps values in PHP memory for the current request only. Used in tests.
Select the store in `.env`. Newer Laravel (11+) uses `CACHE_STORE`; older releases use `CACHE_DRIVER`:
```ini
# Laravel 11 and newer
CACHE_STORE=redis
# Laravel 10 and older
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
```
For production, use Redis. It's fast, shared across web nodes and queue workers, and unlocks tags and locks. If your app can't reach the server, see [Redis connection refused in Laravel](/redis-connection-refused-laravel). The `database` driver was the default in older skeletons; it works but doesn't scale as well under read pressure.
## Basic usage
The facade exposes a small, predictable API.
```php
use Illuminate\Support\Facades\Cache;
Cache::put('key', 'value', now()->addMinutes(10)); // store with TTL
Cache::get('key', 'default'); // read, with fallback
Cache::has('key'); // existence check
Cache::forever('key', 'value'); // no expiry
Cache::forget('key'); // delete one key
```
The method you'll reach for most is `remember()`. It returns the cached value if present, otherwise runs the closure, stores the result, and returns it, so the slow path runs only on a miss:
```php
$users = Cache::remember('users.active', now()->addHour(), function () {
return User::where('active', true)
->withCount('orders')
->get();
});
```
That single call replaces the read/compute/write dance and is the canonical way to wrap a slow query. Use `rememberForever()` for values that never expire on a clock and are invalidated explicitly instead.
## Cache tags for grouped invalidation
When several keys belong together, **tags** let you invalidate them as a group instead of tracking every key by hand. Tags are only supported on the `redis` and `memcached` drivers, not `file` or `database`.
```php
Cache::tags(['users', 'billing'])->put('user.42.invoices', $invoices, 3600);
// Later, blow away everything tagged "users"
Cache::tags(['users'])->flush();
```
This is ideal for per-model caches: tag every entry for a user with `user.{id}`, then flush that one tag when the user changes, leaving the rest of the cache untouched.
## Application caching vs data caching
There's a second kind of caching in Laravel that has nothing to do with the `Cache` facade: **application/config caching**. These Artisan commands compile framework files into a single fast-loading file and matter most in production.
```bash
php artisan config:cache # merge all config into one cached file
php artisan route:cache # compile route definitions
php artisan view:cache # precompile Blade templates
```
Run these on deploy. The catch with `config:cache` is that `env()` calls outside of config files return `null` once config is cached, so read environment values through `config()` only. These caches are about boot speed; the `Cache` facade is about your data. They are independent systems with independent clear commands.
For the bigger picture on tuning a production app, see the [Laravel performance guide](/laravel-performance), and pair the config caches with [OPcache enabled in PHP](/enable-opcache-php) for the largest boot-time gains.
## Clearing the cache
Data cache and application caches clear separately:
```bash
php artisan cache:clear # flush the data cache (Cache facade)
php artisan config:clear # drop cached config
php artisan route:clear # drop cached routes
php artisan view:clear # drop compiled views
php artisan optimize:clear # all of the above at once
```
For the full rundown of when and why to run each, see [clearing the cache in Laravel](/clear-cache-laravel). If you're on Redis specifically and need to flush at the store level, see [clearing the Redis cache](/clear-redis-cache).
## Choosing a driver
A quick decision guide:
- **Local / tiny single server:** `file` — no dependencies, good enough.
- **Tests:** `array` — isolated per request, nothing to clean up.
- **Multi-server, no Redis yet:** `database` — shared and persistent, at some DB cost.
- **Production:** `redis` — fast, shared, supports tags and locks. The recommended default.
If you need cache tags, you must be on `redis` or `memcached`; `file` and `database` silently don't support them.
## Conclusion
The Laravel cache gives you one API over many backends: configure the store in `.env`, wrap slow work in `Cache::remember()`, group related keys with tags for clean invalidation, and keep application/config caching separate in your deploy step. Use Redis in production, and when something looks stale, reach for `cache:clear` or `optimize:clear` before you start debugging. If you run the memcached driver, you'll first need to [install the PHP memcached extension](/install-php-memcached-extension-on-macos).
### How to enable and configure OPcache for faster PHP
Source: https://rocketeersapp.com/enable-opcache-php
OPcache caches compiled PHP bytecode so your code skips recompilation on every request. Here is how to enable and tune OPcache for a real production server.
OPcache is one of the biggest speedups available to a PHP app, and it costs you almost nothing to turn on. Every time PHP runs a script it normally reads the file, parses it, and compiles it to bytecode before executing. OPcache stores that compiled bytecode in shared memory, so subsequent requests skip the parse-and-compile step entirely. On a typical Laravel or WordPress app that alone can cut response times by a large margin.
## What OPcache does
PHP is interpreted, but it doesn't run your source directly. It compiles each `.php` file into intermediate bytecode (opcodes) and then executes that. Without a cache, this compilation happens on *every single request*, for every file the request touches.
OPcache caches the compiled opcodes in shared memory the first time a file runs. After that, PHP fetches the bytecode straight from memory and goes directly to execution. The parsing and compilation overhead disappears, which is why enabling OPcache is usually the first PHP performance change worth making.
## Check whether OPcache is enabled
OPcache ships with PHP and is often already installed, just not configured well. Check from the CLI:
```bash
php -i | grep opcache.enable
```
A clearer view comes from `opcache_get_status()`, which reports live memory usage and hit rate. Note this reflects the CLI SAPI when run from the command line; for FPM, expose it through a web script:
```php
author->name; // queries the authors table every iteration
}
```
Eager load the relationship with `with()` so it's fetched in a single extra query:
```php
// 2 queries total, no matter how many posts
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // already loaded
}
```
You can catch these automatically in development with `Model::preventLazyLoading()` in a service provider, which throws when a relationship is lazy-loaded.
Eager loading reduces the *number* of queries. The speed of each query is a database concern: make sure the foreign-key columns you join on are indexed, or every query still does a full table scan. See [how database indexing works](/database-indexing) for the underlying DB side.
## Offload slow work to queues
Sending email, processing images, calling third-party APIs, generating PDFs, none of it needs to happen inside the request. Push it onto a queue so the user gets an instant response and the work runs in the background.
```php
// Instead of sending inline
Mail::to($user)->send(new WelcomeEmail($user));
// Queue it
Mail::to($user)->queue(new WelcomeEmail($user));
```
Any job can be queued by dispatching it:
```php
ProcessPodcast::dispatch($podcast);
```
Run a worker to process the queue, and keep it alive with a process manager like Supervisor:
```bash
php artisan queue:work --tries=3
```
This moves latency out of the request path, which is often the difference between a 1.5-second page and a 150-millisecond one.
## Use a fast cache and session driver
The default `file` driver writes cache and session data to disk, which is slow and doesn't scale across multiple servers. Switch to Redis for both.
```ini
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
```
Redis is an in-memory store, so reads and writes are sub-millisecond, and it works as a shared backend when you scale to more than one app server. See [using Redis for the Laravel cache](/laravel-cache) for setup and patterns. If your worker can't reach it, check [Redis connection refused in Laravel](/redis-connection-refused-laravel).
## Enable OPcache on the server
PHP recompiles your scripts to bytecode on every request unless OPcache is enabled. OPcache keeps the compiled bytecode in memory, eliminating that work entirely. It's the highest-impact server-side change you can make and applies to any PHP app. See [enabling OPcache in PHP](/enable-opcache-php) for the recommended `php.ini` settings, and [general PHP performance tuning](/php-performance) for more.
## Speed up the frontend
Backend speed is only half the page. Bundle and minify your assets with Vite, which ships with modern Laravel:
```bash
npm run build
```
This produces hashed, minified, tree-shaken bundles and lets the browser cache them aggressively. On the response side, cache rendered output for pages that don't change per-user (marketing pages, docs) and lean on HTTP caching headers so repeat visits skip the server entirely.
## Conclusion
Laravel performance comes down to a short checklist: run `php artisan optimize` and clear it on deploy, build the autoloader with `--optimize-autoloader --no-dev`, eliminate N+1 queries with `with()`, push slow work to queues, use Redis for cache and sessions, and enable OPcache. Do these and a stock Laravel app handles serious traffic before you ever need to think about the framework itself. On a queue-heavy box you can also tune [Horizon's process priority](/laravel-horizon-nice-process-priority); and if you're unsure which release you're on, [check your Laravel version](/check-laravel-version).
### How to optimize MySQL performance
Source: https://rocketeersapp.com/optimize-mysql-performance
A practical guide to MySQL performance tuning that starts with measurement, then covers indexes, the InnoDB buffer pool, connections, and server resources.
MySQL performance tuning is measure-then-change, not copy-pasting a `my.cnf` you found in a forum. Random config tweaks usually do nothing, and occasionally make things worse. The reliable way to optimize MySQL performance is to find what's actually slow, fix that one thing, and confirm it helped before moving on. This guide walks through the levers that matter, roughly in the order you should reach for them.
## Measure first
Before you touch a single setting, find out what's slow. Turn on the slow query log and let it collect real traffic:
```ini
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
```
`long_query_time = 1` logs anything over one second; lower it once the obvious offenders are gone. After a day of traffic, summarise the log with `mysqldumpslow` or `pt-query-digest` to see which queries cost the most total time.
The queries at the top of that list are where your tuning effort belongs. Rewriting one bad query usually beats any config change. See [MySQL query optimization](/mysql-query-optimization) for how to read `EXPLAIN` and fix the offenders.
## Add proper indexes
The single most common cause of a slow query is a missing index. A query that filters or joins on an unindexed column forces a full table scan, which scales linearly with table size: fine at a thousand rows, painful at a million.
```sql
EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'paid';
```
If `EXPLAIN` shows `type: ALL` and `key: NULL`, you're scanning. Add an index that covers the filtered columns:
```sql
ALTER TABLE orders ADD INDEX idx_orders_user_status (user_id, status);
```
Indexing is deep enough to deserve its own treatment, including composite indexes, the leftmost-prefix rule, and what *not* to index. See [how database indexing works](/database-indexing).
## Size the InnoDB buffer pool
After indexes, the buffer pool is the highest-impact setting. `innodb_buffer_pool_size` is the in-memory cache for table and index data; when your working set fits in it, reads come from RAM instead of disk.
On a **dedicated** database server, set it to roughly **50-70% of total RAM**, leaving headroom for the OS, connections, and per-query buffers:
```ini
[mysqld]
innodb_buffer_pool_size = 6G
innodb_buffer_pool_instances = 4
```
To pick a number, first check how much RAM the machine has, see [how much memory is on Ubuntu](/how-much-memory-on-ubuntu). On an 8 GB dedicated box, `6G` is reasonable. On a shared box that also runs PHP-FPM and Nginx, be more conservative, those processes need memory too, and pushing MySQL too high causes swapping.
The default is only 128 MB, so this is almost always worth changing. Verify it took effect:
```sql
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
```
## Connections and "Too many connections"
`max_connections` caps how many clients can connect at once. The default of 151 is fine for many apps, but a traffic spike or a connection leak can exhaust it, and new clients get **ERROR 1040: Too many connections**.
```ini
[mysqld]
max_connections = 300
```
Resist the urge to set this to thousands. Each connection consumes memory, and a high cap can let a runaway app pile up connections until the server runs out of RAM. The real fix is usually shorter-lived connections, a sane pool size in the app, or persistent connections, not a bigger number. See [MySQL 1040: too many connections](/mysql-1040-too-many-connections) for diagnosis and the proper fix.
## The query cache is gone in MySQL 8.0
If an old tuning guide tells you to set `query_cache_size`, ignore it. The query cache was **removed in MySQL 8.0**, it was a global-lock bottleneck that hurt throughput on write-heavy workloads. Don't try to enable it; the variables no longer exist and MySQL won't start if you reference them.
Cache in the application layer instead. Redis or Memcached in front of expensive read queries gives you far more control and none of the contention.
## Temp tables, sort buffers, and logging
A few per-session buffers help specific workloads, but only raise them when the slow log points there:
```ini
[mysqld]
tmp_table_size = 64M
max_heap_table_size = 64M
sort_buffer_size = 4M
```
`tmp_table_size` and `max_heap_table_size` together decide when an in-memory temp table spills to disk; raise both (they must match) if you see `created_tmp_disk_tables` climbing in `SHOW GLOBAL STATUS`. Keep `sort_buffer_size` modest, it's allocated per connection, so a large value multiplied by many connections eats memory fast. Leave the slow query log on in production at a sensible `long_query_time` so regressions surface on their own.
## Server resources
No config can rescue a starved server. MySQL wants RAM, and it must not swap, going to disk for memory turns millisecond queries into multi-second ones. If the box is borderline, add a swap file as a safety valve against the OOM killer, but treat real swapping as a signal to add RAM or shrink the buffer pool. See [add swap space on Ubuntu](/add-swap-space-on-ubuntu).
Storage matters too: use SSDs, always. And if you're still on MySQL 5.7, upgrading to 8.0 is one of the biggest free wins available, a smarter optimizer, better defaults, and improved indexing limits. See [upgrading MySQL 5.7 to 8.0 on Ubuntu](/upgrade-mysql-5-7-to-8-0-ubuntu).
## Conclusion
Optimizing MySQL performance is a loop, not a one-time config dump: measure with the slow query log, fix the worst query (usually with an index), size the buffer pool to your RAM, keep connections sane, and make sure the server has memory to spare without swapping. Change one thing at a time and confirm it helped. That discipline beats any "ultimate `my.cnf`" you'll find online.
### How database indexing works (with MySQL examples)
Source: https://rocketeersapp.com/database-indexing
An index is the single biggest lever you have on query speed. Here is what a database index actually is, how it works under the hood, and how to add the right ones in MySQL.
When a query gets slow, the cause is almost always a missing index. Indexing is the highest-impact thing you can do for database performance, yet it stays a little mysterious. This guide explains what an index is, how it speeds up reads, and how to add and verify indexes in MySQL.
## What is a database index
An index is a separate, sorted data structure that lets the database find rows without scanning the whole table.
The classic analogy is the index at the back of a book. To find every mention of "InnoDB", you don't read all 400 pages, you flip to the index, jump to "I", and get the exact page numbers. A database index does the same thing for your rows.
Without an index, MySQL has to do a **full table scan**, reading every row to find the ones that match. On a few hundred rows that's instant. On a few million it's the difference between 2 milliseconds and 2 seconds.
## How indexes work under the hood
Most MySQL indexes (everything in InnoDB by default) are stored as a **B-tree**: a balanced tree that keeps values in sorted order and stays shallow even for huge tables.
Because the tree is sorted and balanced, MySQL finds any value in a handful of steps instead of a linear scan. A table with a million rows is only a few levels deep, so a lookup touches a handful of nodes rather than a million rows. That same sorted structure is also why an index can satisfy range conditions (`>`, `<`, `BETWEEN`) and `ORDER BY` without sorting afterwards.
The trade-off: the tree has to stay sorted, so every `INSERT`, `UPDATE`, and `DELETE` has to update every affected index too. More on that cost below.
## Creating an index in MySQL
Say you frequently look up users by email:
```sql
SELECT * FROM users WHERE email = 'jane@example.com';
```
If `email` isn't indexed, that's a full table scan. Add an index:
```sql
CREATE INDEX idx_users_email ON users (email);
```
Or while creating/altering the table:
```sql
ALTER TABLE users ADD INDEX idx_users_email (email);
```
If the column should be unique (like an email), use a unique index instead, which enforces uniqueness *and* speeds up lookups:
```sql
ALTER TABLE users ADD UNIQUE INDEX idx_users_email (email);
```
In a Laravel migration the equivalent is:
```php
$table->string('email')->unique(); // unique index
$table->index('last_login_at'); // plain index
```
## Verify the index is actually used
Adding an index is only half the job, you need to confirm MySQL uses it. Put `EXPLAIN` in front of your query:
```sql
EXPLAIN SELECT * FROM users WHERE email = 'jane@example.com';
```
Look at two columns:
- **`type`** — `ALL` means a full table scan (bad). `ref`, `eq_ref`, or `const` means an index is being used (good).
- **`key`** — the index MySQL chose. `NULL` here means no index was used.
If `key` is `NULL` after you added an index, the query usually isn't written in an index-friendly way (see common mistakes below).
## What to index
Index the columns that appear in:
- **`WHERE` filters** — `WHERE status = 'active'`
- **`JOIN` conditions** — the foreign-key columns on both sides
- **`ORDER BY` / `GROUP BY`** — an index can return rows already sorted, skipping a separate sort step
Foreign keys are a common blind spot. A column like `posts.user_id` used in joins should almost always be indexed, otherwise every join does a scan.
## Composite indexes and the leftmost-prefix rule
When a query filters on several columns together, a **composite index** beats several single-column ones:
```sql
ALTER TABLE orders ADD INDEX idx_orders_user_status (user_id, status);
```
This index helps queries that filter on `user_id`, or on `user_id` **and** `status` together:
```sql
SELECT * FROM orders WHERE user_id = 42 AND status = 'paid';
```
The catch is the **leftmost-prefix rule**: MySQL can only use the index left-to-right. The index above helps `user_id` alone, and `user_id + status`, but **not** `status` alone, because `status` isn't the leftmost column. Order the columns by how you actually query them.
## The cost of indexes
Indexes are not free, so don't index every column:
- **Slower writes** — every `INSERT`/`UPDATE`/`DELETE` must update each index. Over-indexing a write-heavy table hurts.
- **Disk and memory** — indexes take space and compete for the InnoDB buffer pool.
- **Maintenance** — redundant indexes (e.g. an index on `(a)` when you already have `(a, b)`) waste resources for no gain.
A good rule of thumb: add indexes to support your real, slow queries, then remove ones that `EXPLAIN` never chooses.
## Common mistakes
- **Wrapping the column in a function** — `WHERE DATE(created_at) = '2026-06-23'` can't use an index on `created_at`. Rewrite as a range: `WHERE created_at >= '2026-06-23' AND created_at < '2026-06-24'`.
- **Leading wildcards** — `WHERE name LIKE '%smith'` can't use an index; `LIKE 'smith%'` can.
- **Indexing low-cardinality columns** — an index on a boolean or a `status` with two values rarely helps on its own.
- **Hitting the key-length limit** — very long `VARCHAR` indexes can fail on older MySQL. See [MySQL 1071: specified key was too long](/mysql-1071-specified-key-was-too-long).
## Conclusion
Indexing is the first place to look when a query is slow. Find the columns in your `WHERE`, `JOIN`, and `ORDER BY` clauses, add a focused index, and confirm with `EXPLAIN` that MySQL uses it, while keeping an eye on write cost so you don't over-index. A unique index is also what raises [duplicate entry (1062)](/sqlstate-23000-1062-duplicate-entry) when a clashing row is inserted.
If you're still on an older MySQL release, upgrading also unlocks better indexing limits and a smarter optimizer, see [upgrading MySQL 5.7 to 8.0 on Ubuntu](/upgrade-mysql-5-7-to-8-0-ubuntu).
### How to increase the PHP memory limit
Source: https://rocketeersapp.com/increase-php-memory-limit
The PHP memory limit caps how much memory a single script can use. Here is how to check it, find the right php.ini, and raise it safely for CLI, FPM, and per-app cases.
`memory_limit` is the maximum amount of memory a single PHP script is allowed to allocate. When a script exceeds it, PHP kills the request with a fatal error rather than letting it eat the whole server. You usually meet the PHP memory limit the hard way, through the dreaded [allowed memory size exhausted](/php-allowed-memory-size-exhausted) error:
```bash
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes)
```
That `134217728` is 128M, the common default. The fix is to raise the limit, but only in the right place. This guide shows where that place is.
## Check the current limit
From the CLI, the quickest check:
```bash
php -i | grep memory_limit
```
Inside a request, drop a `phpinfo()` call or read the value directly:
```php
echo ini_get('memory_limit'); // e.g. "128M"
```
Note that the CLI value and the web (FPM) value are often different, so check both, not just whichever is convenient.
## Find the right php.ini
This is the step that trips people up. The PHP CLI and PHP-FPM load **different** configuration files. Editing the CLI's `php.ini` will do nothing for your web requests, and vice versa.
Ask PHP itself which file it uses:
```bash
php --ini
```
```text
Configuration File (php.ini) Path: /etc/php/8.3/cli
Loaded Configuration File: /etc/php/8.3/cli/php.ini
```
That `cli` path is for command-line scripts. The web server uses the FPM file, typically `/etc/php/8.3/fpm/php.ini`. Confirm the FPM path from a `phpinfo()` page served through your web server.
## Set memory_limit in php.ini
Open the correct file and set the value:
```ini
memory_limit = 256M
```
Use a plain integer plus a unit suffix: `K`, `M`, or `G`. A value of `-1` means **unlimited**, which is discouraged on a web server, because a single runaway request can starve the whole machine. Pick a real ceiling instead.
Reload so the change takes effect. For the CLI, no reload is needed, the next `php` invocation picks it up. For FPM, restart the service:
```bash
sudo systemctl restart php8.3-fpm
```
## Per PHP-FPM pool override
You often want a higher limit for one app, not every site on the box. PHP-FPM pools let you override `memory_limit` per pool, in the pool config (e.g. `/etc/php/8.3/fpm/pool.d/www.conf`):
```ini
php_admin_value[memory_limit] = 512M
```
`php_admin_value` sets the limit and prevents the application from lowering or overriding it at runtime. After editing the pool, restart FPM:
```bash
sudo systemctl restart php8.3-fpm
```
This is the cleanest way to give a memory-hungry app more headroom while keeping the global default conservative. While you are in the pool config, it is worth [disabling unused PHP-FPM pools](/disable-unused-php-fpm-pools) so they aren't holding resources.
## Per-app and per-directory overrides
If you can't touch the global config, raise the PHP memory limit closer to the application.
**`.user.ini`** — drop a file in your app's web root (works with PHP-FPM/CGI):
```ini
memory_limit = 256M
```
Changes are cached and picked up after `user_ini.cache_ttl` (300 seconds by default), so they aren't instant.
**`ini_set()` at runtime** — raise it for a single script before the heavy work starts:
```php
ini_set('memory_limit', '512M');
```
This fails if the limit was locked with `php_admin_value`, and it can't help a script that runs out of memory before this line executes.
**`.htaccess`** — on Apache with `mod_php`:
```apache
php_value memory_limit 256M
```
This does nothing under PHP-FPM, which ignores `.htaccess` PHP directives.
## Apply it only where needed
Resist the urge to set a huge limit globally. A high web limit lets one bad request consume gigabytes; `-1` everywhere removes the safety net entirely. Raise the limit for the specific pool, directory, or script that needs it, and leave the global default sane.
Remember the CLI/web split. Long-running command-line jobs legitimately need more memory than web requests, which is why Composer in particular hits the wall, see [Composer out of memory: allowed memory size exhausted](/composer-out-of-memory-allowed-memory-size-exhausted). Because the CLI uses its own `php.ini`, you can give it a generous limit (or `-1`) without loosening anything your web traffic touches.
## Conclusion
Raising the PHP memory limit is straightforward once you know which `php.ini` is in play: confirm with `php --ini`, set `memory_limit` in the right file, and restart FPM for web changes. Override per pool, per directory, or per script when only one app needs more, and keep the global default modest so a single request can't take down the server. If you're tuning memory because pages are slow rather than crashing, that's a different problem, start with [PHP performance](/php-performance).
### Brotli vs Gzip: which compression should you use?
Source: https://rocketeersapp.com/brotli-vs-gzip
Brotli vs Gzip is the practical choice for compressing text responses. Here is how the two differ on ratio, speed, and support, and which one to serve.
Both Brotli and Gzip do the same job: they shrink text responses (HTML, CSS, JS, JSON) before sending them over the wire so pages load faster on the client. The Brotli vs Gzip question isn't really "which is better" in the abstract, it's which one to use for which kind of asset. This guide covers the practical differences in ratio, speed, and browser support, then gives a recommendation you can act on.
## What Gzip is
Gzip is the long-standing standard for HTTP compression. It's built on the **DEFLATE** algorithm (LZ77 plus Huffman coding) and has been supported by every browser and server worth caring about for two decades.
Its strengths are universal support and speed. Gzip compresses quickly even at higher levels, so it's safe to use on dynamic responses generated per request. It's the safe default that works everywhere.
## What Brotli is
Brotli is a newer algorithm developed at Google, designed specifically with the web in mind. It uses similar techniques to DEFLATE but adds a few things that pay off on text: larger compression windows, context modelling, and a **built-in dictionary** of common words and HTML/CSS/JS fragments.
That built-in dictionary is the key difference. Because web responses share a lot of boilerplate, Brotli can reference common strings without spending bytes describing them, which is why it tends to beat Gzip on exactly the content most sites serve.
## Compression ratio
On text, Brotli generally produces smaller output than Gzip at comparable settings, often in the rough range of **15-25% smaller** for HTML, CSS, and JS. The exact savings depend heavily on the file and the levels you compare, so treat that as a ballpark rather than a guarantee.
The gain is largest on text-heavy assets. For already-compressed binaries (images, fonts in WOFF2, video) neither algorithm helps much, so don't bother compressing those.
## Speed and levels
This is where the choice actually gets decided.
- **Gzip** runs at levels **1-9**. Higher means smaller but slower; the middle levels are a good balance for on-the-fly compression.
- **Brotli** runs at levels **0-11**. The top level (11) squeezes out the best ratio but is **slow to compress** — far slower than Gzip.
That speed cost shapes how you use each one:
| Scenario | Best choice |
| --- | --- |
| Static assets (CSS/JS bundles) | Brotli at level 11, precompressed at build time |
| Dynamic responses (HTML, API JSON) | Brotli at a low level, or Gzip |
| Maximum compatibility | Gzip as the fallback |
The insight: Brotli 11 is expensive to run once but cheap to serve forever, so it's ideal for **static, precompressed assets**. For dynamic responses you pay the cost on every request, so a lower Brotli level or plain Gzip is the better trade.
## Browser support
Both are effectively universal in modern browsers. Gzip works everywhere. Brotli is supported by all current browsers, with the one caveat that they only advertise it **over HTTPS**.
The browser tells the server what it accepts in the `Accept-Encoding` request header:
```http
Accept-Encoding: br, gzip
```
`br` is Brotli. The server picks the best encoding it supports from that list and signals its choice back in the `Content-Encoding` response header. If a client only sends `gzip`, you fall back to Gzip automatically.
## Recommendation
You don't have to pick one. Serve **Brotli when the client supports it, with Gzip as the fallback** — this is how a well-configured server already behaves based on `Accept-Encoding`.
The one decision worth making deliberately:
- **Static assets** (your built CSS/JS): precompress them at build time with Brotli at level 11. The server then serves the `.br` file directly with no per-request cost.
- **Dynamic responses** (HTML, JSON APIs): use a low Brotli level or Gzip so compression doesn't add latency to [time to first byte](/measure-ttfb).
Compression is one of the highest-leverage things you can do for [overall site performance](/optimize-website-performance), and it costs almost nothing to turn on.
## How to enable it
On Nginx, both algorithms are configured in a few lines. The Gzip side is covered step by step in [enable Gzip compression on Nginx](/enable-gzip-compression-nginx); Brotli works the same way once the `ngx_brotli` module is loaded, with directives for both dynamic compression and serving precompressed `.br` files.
## Conclusion
In the Brotli vs Gzip comparison there's no real loser. Brotli wins on ratio for text and is the right choice for precompressed static assets at level 11. Gzip is the universal, fast fallback that's hard to go wrong with. Configure your server to prefer Brotli and fall back to Gzip, precompress your static bundles, and skip compressing already-compressed binaries.
### How to measure TTFB (Time To First Byte)
Source: https://rocketeersapp.com/measure-ttfb
TTFB is one of the most important metrics to measure your website or webapp performance. Because only after the first byte the browser can begin to render it.
The performance of a web server is very important for your web application. The first metric that comes to mind when measuring web application performance is the TTFB (Time To First Byte).
This number shows the time it takes for the server to return the first byte in response of a request from a client.
We can easily get the time it takes for the server to process a HTTP request using the following command:
```bash
URL="https://rocketee.rs"
curl -o /dev/null \
-H 'Cache-Control: no-cache' \
-s \
-w "TTFB: %{time_starttransfer}" \
$URL
```
This command takes care of two important things:
1. Make sure we don't get an earlier cached version of the webpage
2. Only shows the number we're interested in using silent mode and directing output to `/dev/null`
### How to optimize website performance
Source: https://rocketeersapp.com/optimize-website-performance
Learn how to improve your website or web app performance by leveraging different techniques that optimize the loading speed drastically for your users.
**This article will be continuously updated with new content.**
### How to optimize server performance
Source: https://rocketeersapp.com/optimize-server-performance
Server performance is determinded by a lot of moving parts inside your server. Lets dig in on every aspect that can make a big difference.
**This article will be continuously updated with new content.**
## Disable unnecessary and unused PHP versions (FPM pools)
When running PHP applications or websites it's a common mistake to keep unused PHP versions running on your server. Mostly this happens when you upgrade the default PHP server version or add a new PHP version to run your application on. In the background these processes do not much harm, but they always will be occupying precious server memory.
[Disable unnecessary and unused PHP versions (FPM pools)](/disable-unused-php-fpm-pools)
## Make sure enough diskspace is available
When a server does not have enough (a few GB's) of diskspace available, it cannot run within optimal conditions. Because of this, as a treshold make sure there is more than 20% available of the total diskspace capacity. To make sure we have enough diskspace available, there are some commands that can help you with this.
[Reclaim diskspace on Ubuntu server](/reclaim-diskspace-on-ubuntu)
## Add Swap Space to your server
To increase performance you need to make sure your server has enough memory to make sure it can execute every task. While swap space is slower than usual RAM memory, it is recommended to add at least some (1-2GB) swap space to keep the server running optimal in every situation. Watch what's consuming resources with [top processes by CPU](/top-processes-cpu) and [by memory](/top-processes-memory); Rocketeers can [monitor your sites and servers](/features/monitor-your-sites-and-servers) for you.
[Learn how to add Swap Space to Ubuntu servers](/add-swap-space-on-ubuntu)
## Security
### How to install Certbot
Source: https://rocketeersapp.com/how-to-install-certbot
Certbot issues free, trusted SSL certificates from Let's Encrypt and renews them before they expire. Here is how to install it on Ubuntu, issue your first certificate, and keep it renewing on its own.
A trusted SSL certificate is no longer optional — without one every visitor gets a [your connection is not private](/your-connection-is-not-private) warning, and browsers refuse to load the page. [Certbot](https://certbot.eff.org) is the official client for [Let's Encrypt](https://letsencrypt.org), which hands out free certificates that every browser trusts. The catch is that those certificates only last 90 days, so installing Certbot is really about setting up a process that renews them forever.
We assume you already have a Ubuntu server with [Nginx installed](/how-to-install-nginx) and a domain pointed at it.
## Install Certbot in a virtual environment
You'll find guides that install Certbot with `apt install certbot` or through snap. Both work, but the `apt` package is often stuck on an old release, and snap drags in its own runtime. The cleanest approach — and the one that gives you the DNS plugins you'll want later — is to install Certbot into its own Python virtual environment.
First install Python and the tools to create the environment:
```bash
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y openssl python3 python3-venv
```
Create the virtual environment in `/opt/certbot` and upgrade pip inside it:
```bash
sudo python3 -m venv /opt/certbot
sudo /opt/certbot/bin/pip install --upgrade pip
```
Now install Certbot itself, along with the DNS provider plugins you might need:
```bash
sudo /opt/certbot/bin/pip install --upgrade \
certbot \
certbot-dns-cloudflare \
certbot-dns-digitalocean \
certbot-dns-dnsimple \
certbot-dns-hetzner
```
Finally, symlink the binary onto your `PATH` so you can just type `certbot`:
```bash
sudo ln -sf /opt/certbot/bin/certbot /usr/local/bin/certbot
```
Check it worked:
```bash
certbot --version
```
## Issue your first certificate
There are two ways to prove to Let's Encrypt that you actually control the domain: the HTTP challenge and the DNS challenge.
### The HTTP challenge (webroot)
The HTTP challenge has Let's Encrypt fetch a token from a file Certbot drops in your web root. Create the directory it serves the challenge from, then request the certificate:
```bash
sudo mkdir -p /var/www/example.com/.well-known/acme-challenge
sudo certbot certonly \
--webroot --webroot-path /var/www/example.com \
--preferred-challenges http \
--cert-name example.com \
--domains example.com,www.example.com \
--email you@example.com \
--rsa-key-size 4096 \
--agree-tos \
--non-interactive
```
This needs your domain's DNS already pointing at the server and port 80 reachable.
### The DNS challenge
The DNS challenge proves control by creating a temporary TXT record through your DNS provider's API. It's the only option for wildcard certificates (`*.example.com`) and it works before your domain even points at the server. Store your provider credentials in a file and lock it down:
```bash
sudo mkdir -p /root/.secrets
echo "dns_cloudflare_api_token = your-token-here" | sudo tee /root/.secrets/cloudflare.ini
sudo chmod 600 /root/.secrets/cloudflare.ini
```
Then issue the certificate using the matching plugin:
```bash
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
--cert-name example.com \
--domains example.com,*.example.com \
--email you@example.com \
--rsa-key-size 4096 \
--agree-tos \
--non-interactive
```
Either way, your certificate and private key land in `/etc/letsencrypt/live/example.com/`.
## Point Nginx at the certificate
Reference the issued files in your server block, then reload Nginx:
```nginx
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
```
```bash
sudo nginx -t && sudo service nginx reload
```
Always run `nginx -t` first — reloading with a broken config can leave you with a [502 Bad Gateway](/502-bad-gateway-nginx) or worse.
## Keep it renewing automatically
Let's Encrypt certificates expire after 90 days, so renewal isn't a nice-to-have — it's the whole point. Certbot can renew every certificate it manages with one command:
```bash
sudo certbot renew --quiet
```
Test it without touching anything real first:
```bash
sudo certbot renew --dry-run
```
To make it happen on its own, drop a cron job that renews daily and reloads Nginx afterwards so it picks up the fresh certificate:
```bash
echo '0 0 * * * root /usr/local/bin/certbot renew --quiet --post-hook "service nginx reload" > /dev/null 2>&1' \
| sudo tee /etc/cron.d/certbot
```
Certbot only actually renews a certificate when it's within 30 days of expiry, so running daily is safe and gives you a wide margin if a renewal ever fails. We cover the renewal setup in more depth in [renew SSL certificates automatically](/renew-ssl-certificates-automatically).
## Confirm it's working
Check what Certbot is managing and when each certificate expires:
```bash
sudo certbot certificates
```
You can also verify the live certificate the way a browser sees it — see [how to check SSL certificate expiration](/check-ssl-certificate-expiration).
## Let Rocketeers handle it
Installing Certbot is the easy part. The work that never ends is the part around it: storing each provider's API credentials securely, picking the right challenge per domain, wiring the renewal hook into Nginx, and noticing when a renewal silently fails three months from now. Rocketeers provisions Certbot with every DNS plugin, issues certificates over HTTP or DNS automatically, and renews them in the background for every site on every server you run — so a certificate never lapses and you never think about it again.
### How to renew SSL certificates automatically
Source: https://rocketeersapp.com/renew-ssl-certificates-automatically
Let's Encrypt certificates last 90 days, so renewal has to be automatic. Here is how to set up Certbot to renew every certificate and reload Nginx on its own.
A [Let's Encrypt](https://letsencrypt.org) certificate is valid for 90 days. That short lifetime is deliberate — it forces automation, so a forgotten certificate can never linger and become a problem. The flip side is that renewal is not optional: if it isn't automatic, your site eventually goes dark with a [your connection is not private](/your-connection-is-not-private) warning. Here's how to make sure that never happens.
This assumes you've already [installed Certbot](/how-to-install-certbot) and issued a certificate.
## How Certbot renewal works
When Certbot issues a certificate it saves a renewal config under `/etc/letsencrypt/renewal/`, recording the domains, the challenge method, and the credentials it used. From then on, one command renews everything it manages:
```bash
sudo certbot renew
```
The important detail: `certbot renew` only actually renews a certificate when it's within 30 days of expiry. Every other run is a no-op. That's why it's safe — and recommended — to run it far more often than the certificate's lifetime would suggest.
## Test renewal first
Before trusting it, do a dry run against the staging servers. It exercises the entire renewal path without touching your real certificates or hitting rate limits:
```bash
sudo certbot renew --dry-run
```
If that completes cleanly, automatic renewal will work.
## Set up the renewal cron job
Add a cron entry that renews daily and reloads Nginx afterwards — the reload is what makes Nginx pick up the new certificate without dropping connections:
```bash
echo '0 0 * * * root /usr/local/bin/certbot renew --quiet --post-hook "service nginx reload" > /dev/null 2>&1' \
| sudo tee /etc/cron.d/certbot
```
Breaking that down:
- `0 0 * * *` runs it every day at midnight.
- `--quiet` keeps it silent unless something actually happens.
- `--post-hook "service nginx reload"` reloads Nginx **only** when a certificate was renewed, so it isn't reloading needlessly every day.
Running daily means that even if one renewal fails, you have ~30 days of retries before the certificate actually expires.
## The systemd alternative
On modern Ubuntu, Certbot often installs a `systemd` timer that does the same job. Check whether one is already active before adding a cron job:
```bash
systemctl list-timers | grep certbot
```
If you see `certbot.timer`, renewal is already scheduled — you only need to make sure a reload hook is configured. Use one mechanism or the other, not both.
## Confirm what's scheduled to renew
List every certificate Certbot manages and its expiry date:
```bash
sudo certbot certificates
```
For peace of mind, [check the expiry of the live certificate](/check-ssl-certificate-expiration) the way a browser sees it, and consider a monitoring alert so you hear about a stalled renewal long before visitors do.
## Let Rocketeers handle it
Automatic renewal is a chain — the cron job has to exist, the reload hook has to fire, the DNS or HTTP challenge has to keep working, and someone has to notice if a renewal quietly fails three months from now. Rocketeers renews every certificate across every server it manages and reloads the web server for you, and it surfaces any failure so a certificate never silently lapses. If you'd rather not run certificates on the origin at all, you can also [terminate SSL at Cloudflare](/a-plus-grade-ssl-using-cloudflare).
### How to configure rate limiting in nginx
Source: https://rocketeersapp.com/nginx-rate-limiting
Rate limiting protects login pages, APIs, and expensive endpoints from abuse and brute-force attacks. nginx does this with the built-in limit_req module. Here is how to configure it.
## How nginx rate limiting works
nginx rate limiting has two parts: you define a **zone** that tracks request rates per client in the `http` block, then **apply** that zone to the locations you want to protect. The `limit_req` module uses a leaky-bucket algorithm, so a steady rate is allowed while bursts are smoothed out or rejected.
## Define a rate-limit zone
Add this inside the `http` block (in `nginx.conf`):
```
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
```
- `$binary_remote_addr` keys the limit on the client IP address (compact form).
- `zone=mylimit:10m` names the zone and reserves 10 MB of shared memory — enough for roughly 160,000 IPs.
- `rate=10r/s` allows 10 requests per second per IP. Use `r/m` for per-minute limits on sensitive endpoints.
## Apply the limit to a location
Reference the zone inside a `server` or `location` block:
```
location /login {
limit_req zone=mylimit burst=20 nodelay;
# ... your usual proxy_pass or fastcgi_pass
}
```
- `burst=20` lets a short spike of up to 20 queued requests through instead of rejecting them immediately.
- `nodelay` serves those burst requests right away rather than spacing them out — usually what you want for web traffic.
## Return 429 instead of 503
By default nginx rejects limited requests with `503 Service Unavailable`. `429 Too Many Requests` is more accurate and friendlier to API clients:
```
limit_req_status 429;
```
## Protect a login endpoint against brute force
A tight per-minute limit is ideal for login and password-reset pages:
```
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
location = /login {
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
# ...
}
```
## Apply and test
Check the configuration before reloading so a typo doesn't take the site down:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
You can confirm it's working by hammering the endpoint and watching for `429` responses:
```bash
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" https://example.com/login; done
```
Rate limiting pairs well with other server-level protections such as a [Content Security Policy in nginx](/content-security-policy) and the broader checklist in [optimizing web application security](/optimize-web-application-security).
### Convert SSL certificate formats with OpenSSL
Source: https://rocketeersapp.com/convert-ssl-certificate-formats
Certificates come in PEM, CRT, CER, DER, and PFX formats, and software is picky about which one it wants. Here are the openssl commands to convert between all of them.
## The formats, quickly
Before converting, it helps to know what you actually have:
- **PEM** — Base64 text, starts with `-----BEGIN CERTIFICATE-----`. The most common format on Linux. Can hold a certificate, a key, or a whole chain.
- **CRT / CER** — usually just a PEM (or DER) certificate with a different file extension.
- **DER** — the binary version of a PEM certificate. Common in the Windows and Java world.
- **PFX / P12** — a binary, password-protected bundle holding the certificate **and** its private key together. Standard on Windows / IIS.
Everything below uses the `openssl` command line tool.
## PEM and CRT
These are typically the same format, so converting is often just a rename. To be safe, normalise to PEM:
```bash
openssl x509 -in certificate.crt -out certificate.pem -outform PEM
```
## PEM to DER
```bash
openssl x509 -in certificate.pem -outform DER -out certificate.der
```
## DER to PEM
```bash
openssl x509 -in certificate.der -inform DER -out certificate.pem -outform PEM
```
## PFX to PEM (certificate and key)
A PFX bundles both parts. To pull them out into a single PEM file:
```bash
openssl pkcs12 -in certificate.pfx -out certificate.pem -nodes
```
If you only want one part, see the dedicated guides for [extracting the certificate from a PFX file](/extract-certificate-from-pfx-file) and [extracting the private key from a PFX file](/extract-private-key-from-pfx-file).
## PEM/CRT and key to PFX
Going the other way — bundling a certificate and its private key into a PFX for Windows or IIS:
```bash
openssl pkcs12 -export -out certificate.pfx \
-inkey private.key -in certificate.crt
```
Add the intermediate certificate so the bundle carries the full chain:
```bash
openssl pkcs12 -export -out certificate.pfx \
-inkey private.key -in certificate.crt -certfile intermediate.crt
```
You'll be prompted for an export password, which whoever imports the PFX will need.
## Verify the result
After any conversion, confirm the certificate is readable and contains what you expect:
```bash
openssl x509 -in certificate.pem -noout -text
```
If your goal is a working HTTPS setup, make sure the certificate you serve includes the intermediates — see [what is an SSL certificate chain](/what-is-an-ssl-certificate-chain).
### How to generate an SSH key
Source: https://rocketeersapp.com/generate-ssh-key
Here is how to generate an SSH key pair on Linux, macOS, or Windows and add the public key to a server, GitHub, or GitLab.
If you're not sure what an SSH key is or how it works, start with [what is an SSH key](/what-is-an-ssh-key). Otherwise, here's how to make one.
## Generate the key pair
Use `ssh-keygen` with the modern ed25519 algorithm. The comment (`-C`) is just a label to help you recognise the key later:
```bash
ssh-keygen -t ed25519 -C "you@example.com"
```
You'll be asked where to save the key (press Enter for the default `~/.ssh/id_ed25519`) and for an optional passphrase. A passphrase encrypts your private key on disk — recommended for laptops.
If you need to support older systems that don't speak ed25519, generate an RSA key instead:
```bash
ssh-keygen -t rsa -b 4096 -C "you@example.com"
```
## Where the files end up
The command creates two files in `~/.ssh`:
- `id_ed25519` — your **private** key. Never share or copy this off your machine.
- `id_ed25519.pub` — your **public** key. This is the one you hand out.
## Add the key to the ssh-agent
The agent keeps your unlocked key in memory so you don't retype the passphrase every time:
```bash
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
```
## Copy the public key to a server
The easiest way to install your public key on a server is `ssh-copy-id`:
```bash
ssh-copy-id user@your-server
```
This appends your public key to the server's `~/.ssh/authorized_keys`. From then on you can log in without a password:
```bash
ssh user@your-server
```
If `ssh-copy-id` isn't available, print the public key and paste it into `~/.ssh/authorized_keys` on the server yourself:
```bash
cat ~/.ssh/id_ed25519.pub
```
## Add the key to GitHub or GitLab
Copy the public key to your clipboard, then paste it into **Settings → SSH and GPG keys** (GitHub) or **Preferences → SSH Keys** (GitLab):
```bash
# macOS
pbcopy < ~/.ssh/id_ed25519.pub
# Linux (X11)
xclip -sel clip < ~/.ssh/id_ed25519.pub
```
Test the connection afterwards:
```bash
ssh -T git@github.com
```
## If the connection is rejected
If the server or service refuses your key, the fixes are in [SSH Permission denied (publickey)](/ssh-permission-denied-publickey) and, for GitHub specifically, [GitHub Permission denied (publickey)](/github-permission-denied-publickey).
### How to generate a CSR with OpenSSL
Source: https://rocketeersapp.com/generate-csr-with-openssl
A CSR (Certificate Signing Request) is the file you send to a certificate authority to request an SSL certificate. Here is how to generate one, and its private key, with openssl.
## What a CSR contains
A Certificate Signing Request bundles the details of the certificate you want — your domain name and organisation info — and your **public** key, all signed by your **private** key. The CA uses it to issue a certificate. The private key it's generated alongside stays with you and is never sent to the CA.
## Generate a private key and CSR
This single command creates a new 2048-bit private key and a matching CSR:
```bash
openssl req -new -newkey rsa:2048 -nodes \
-keyout domain.key -out domain.csr
```
You'll be prompted for the certificate details. The important one is **Common Name** — it must be the exact domain you're securing, for example `www.example.com`.
`-nodes` leaves the private key unencrypted, which is what web servers expect. Two files result:
- `domain.key` — your private key. Keep it safe; you'll need it to install the certificate.
- `domain.csr` — the request to send to your CA.
## Generate a CSR with Subject Alternative Names (SAN)
Modern certificates should list every hostname under SAN, not just the Common Name. Pass them inline:
```bash
openssl req -new -newkey rsa:2048 -nodes \
-keyout domain.key -out domain.csr \
-subj "/CN=example.com" \
-addext "subjectAltName=DNS:example.com,DNS:www.example.com"
```
## Generate a CSR from an existing key
If you already have a private key and just need a new request (for a renewal, say):
```bash
openssl req -new -key domain.key -out domain.csr
```
## Verify the CSR before sending it
Always check the request decodes correctly and lists the right names:
```bash
openssl req -in domain.csr -noout -text
```
Once your CA returns the signed certificate, you may need to [convert it to another format](/convert-ssl-certificate-formats) and make sure you serve the [complete certificate chain](/what-is-an-ssl-certificate-chain). For local development where you don't need a CA at all, generate a [self-signed certificate](/generate-self-signed-certificate-openssl) instead.
### How to change the SSH port on Ubuntu
Source: https://rocketeersapp.com/change-ssh-port-ubuntu
Moving SSH off the default port 22 cuts down on automated brute-force noise in your logs. Here is how to do it safely on Ubuntu, including the socket-activation change that catches people out on 24.04, without locking yourself out.
Changing the port is not real security on its own — see [optimizing web application and server security](/optimize-web-application-security) for the controls that matter — but it dramatically reduces the volume of drive-by login attempts. Here's how to do it without losing access to your server. (For what the default port is and why it matters, see [the SSH port explained](/ssh-port).)
## Set the new port in sshd_config
Edit the SSH daemon config:
```bash
sudo nano /etc/ssh/sshd_config
```
Find the `Port` line, uncomment it, and set your chosen port (pick something above 1024, for example 2222):
```
Port 2222
```
## Ubuntu 22.10 and newer: update the socket too
This is the step most guides miss. On Ubuntu 22.10, 24.04, and later, `ssh` is **socket-activated** by systemd, so the `Port` in `sshd_config` is ignored — the listening port is defined in `ssh.socket` instead.
Check whether socket activation is in use:
```bash
sudo systemctl status ssh.socket
```
If it's active, override the socket's port:
```bash
sudo systemctl edit ssh.socket
```
Add these lines in the editor (the empty `ListenStream=` clears the default of 22):
```
[Socket]
ListenStream=
ListenStream=2222
```
On older releases (Ubuntu 22.04 and earlier) there's no socket — the `sshd_config` change alone is enough.
## Open the new port in the firewall
If you run UFW, allow the new port **before** restarting, or you'll lock yourself out:
```bash
sudo ufw allow 2222/tcp
```
## Apply the change
Reload systemd and restart SSH:
```bash
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket ssh
```
## Test in a new session — don't close the old one
Keep your current SSH session open. From another terminal, connect on the new port:
```bash
ssh -p 2222 user@your-server
```
Only once that succeeds should you close the original session. If the new connection is refused or rejected, work through [SSH Permission denied (publickey)](/ssh-permission-denied-publickey) and double-check the firewall rule.
## Clean up the firewall
After confirming the new port works, remove the old rule if you'd added one for port 22:
```bash
sudo ufw delete allow 22/tcp
```
### What is an SSH key
Source: https://rocketeersapp.com/what-is-an-ssh-key
An SSH key is a pair of cryptographic keys used to log in to servers and services without a password. The public key lives on the server, the private key stays on your machine, and only the two together grant access.
## How SSH keys work
An SSH key comes as a **pair**: a private key and a public key. They are generated together and are mathematically linked.
- The **private key** stays on your computer and is never shared. Treat it like a password.
- The **public key** is copied to any server or service you want to access.
When you connect, the server uses your public key to issue a challenge that only the matching private key can answer. Your private key never leaves your machine, and no secret is sent over the network. If the answer checks out, you're in.
## Why use keys instead of passwords
- **More secure** — a 256-bit key is effectively impossible to brute-force, unlike a typed password.
- **No password prompts** — once set up, connections are automatic, which makes scripting and deployments painless.
- **Easy to revoke** — remove one public key from a server to cut off one machine, without changing anything else.
This is why password authentication is often disabled entirely on hardened servers. See [optimizing web application and server security](/optimize-web-application-security) for the bigger picture.
## Key types
When you generate a key you choose an algorithm. In 2026 the recommendation is simple:
- **ed25519** — fast, secure, and short. Use this unless you have a specific reason not to.
- **rsa** — still fine at 4096 bits, and the most widely compatible with older systems.
- **ecdsa** — supported, but ed25519 is the better modern choice.
## Where SSH keys live
On your machine, keys are stored in `~/.ssh`:
- `~/.ssh/id_ed25519` — your private key.
- `~/.ssh/id_ed25519.pub` — your public key.
On a server, the public keys that are allowed to log in to an account are listed in that account's `~/.ssh/authorized_keys` file.
## Generate a key
Creating a key takes one command:
```bash
ssh-keygen -t ed25519 -C "you@example.com"
```
For the full walkthrough — including copying the key to a server and adding it to GitHub — see [how to generate an SSH key](/generate-ssh-key).
If the server rejects your key when you connect, the cause is almost always covered in [SSH Permission denied (publickey)](/ssh-permission-denied-publickey).
### How to check SSL certificate expiration
Source: https://rocketeersapp.com/check-ssl-certificate-expiration
Here is how to check when an SSL certificate expires, both for a live website and for a certificate file on disk, using openssl from the command line.
An expired certificate takes a site down hard — every visitor sees [your connection is not private](/your-connection-is-not-private). Checking the expiry date takes one command.
You can also run the same check in your browser with our free [SSL checker](/ssl).
## Check a live website
Connect to the site and read the validity dates straight from the certificate it serves:
```bash
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -dates
```
You'll get the start and end of the validity window:
```bash
notBefore=Mar 1 00:00:00 2026 GMT
notAfter=May 30 23:59:59 2026 GMT
```
To see only the expiry date, swap `-dates` for `-enddate`:
```bash
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -enddate
```
## Check a certificate file on disk
If you have the certificate as a local file:
```bash
openssl x509 -in certificate.pem -noout -enddate
```
This works on any PEM or CRT file. For other formats, see [converting certificate formats](/convert-ssl-certificate-formats) first.
## Check whether it expires within N days
The `-checkend` flag is built for scripts and monitoring. It takes a number of seconds and exits non-zero if the certificate expires within that window. This checks for the next 30 days (30 × 86400 = 2592000 seconds):
```bash
openssl x509 -in certificate.pem -noout -checkend 2592000
```
```bash
echo $? # 0 = still valid, 1 = expires within 30 days
```
Wire that exit code into a cron job or a monitoring check and you'll get warned before a certificate ever lapses. If your certificate is valid but the connection still fails, the cause is often a [missing certificate chain](/what-is-an-ssl-certificate-chain) rather than the expiry date.
### How to generate a self-signed certificate with OpenSSL
Source: https://rocketeersapp.com/generate-self-signed-certificate-openssl
A self-signed certificate lets you serve HTTPS for local development or internal services without going through a certificate authority. Here is how to create one with openssl.
## When to use a self-signed certificate
A self-signed certificate is signed by its own key rather than a trusted CA. That's perfect for **local development, testing, and internal services** where you control the clients. It is **not** suitable for a public website — browsers don't trust it and will show [your connection is not private](/your-connection-is-not-private) to every visitor.
## Generate a certificate and key in one command
This creates a private key and a self-signed certificate valid for one year:
```bash
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout key.pem -out cert.pem -days 365 \
-subj "/CN=localhost"
```
- `cert.pem` — the certificate.
- `key.pem` — the private key.
`-nodes` keeps the key unencrypted so a web server can read it without a passphrase.
## Include Subject Alternative Names
Modern browsers ignore the Common Name and require the hostname under **SAN**, or they'll reject the certificate outright. Add it explicitly:
```bash
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout key.pem -out cert.pem -days 365 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
```
## Use it in nginx
Point your server block at the two files:
```
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
```
## Trust it locally
Your browser will still warn you because nothing vouches for the certificate. For a smoother local setup you can add `cert.pem` to your operating system or browser trust store, or use a tool like `mkcert` that installs a local CA for you.
When you're ready to serve real traffic, request a certificate from a CA with a [CSR](/generate-csr-with-openssl), or use a free automated certificate by [installing Certbot](/how-to-install-certbot). Either way, make sure you serve the [full certificate chain](/what-is-an-ssl-certificate-chain).
### What is an SSL certificate chain
Source: https://rocketeersapp.com/what-is-an-ssl-certificate-chain
An SSL certificate chain links your website's certificate back to a trusted root certificate. When a link in that chain is missing, browsers and command line tools reject the connection even though the certificate itself is perfectly valid.
## What is a certificate chain
When a browser connects to your website over HTTPS, it doesn't trust your certificate directly. Instead it follows a **chain of trust** from your certificate up to a root certificate it already trusts. Each certificate in the chain is signed by the one above it, and the root is pre-installed in the browser or operating system trust store.
If the browser can build an unbroken path from your certificate to a trusted root, the connection is secure. If it can't, it shows a warning.
## The three links in the chain
A complete chain has three types of certificate:
- **Root certificate** — owned by the Certificate Authority (CA) and shipped inside every browser and operating system. It is never sent over the wire; the client already has it.
- **Intermediate certificate(s)** — signed by the root, used by the CA to issue your certificate. There can be more than one.
- **Leaf certificate** — also called the server or end-entity certificate. This is the one issued for your domain.
The browser ships the root. **Your server must send the leaf certificate plus every intermediate certificate** so the browser can connect the two ends.
## Why an incomplete chain breaks HTTPS
The most common SSL mistake is installing only the leaf certificate and forgetting the intermediates. It often looks fine in your own browser (which may have cached the intermediate from another site) but fails for other visitors and for tools like `curl`.
A broken or incomplete chain typically shows up as:
- [curl (60) SSL certificate problem: unable to get local issuer certificate](/curl-60-ssl-certificate-problem-unable-to-get-local-issuer-certificate)
- [NET::ERR_CERT_AUTHORITY_INVALID](/net-err-cert-authority-invalid)
- [Your connection is not private](/your-connection-is-not-private)
## Inspect the chain a server is sending
Use `openssl` to see exactly which certificates your server presents:
```bash
openssl s_client -connect example.com:443 -servername example.com -showcerts
```
Each `-----BEGIN CERTIFICATE-----` block is one certificate in the chain. You should see your leaf certificate followed by one or more intermediates. If you only see one certificate, your chain is incomplete.
## Fix an incomplete chain
The fix is to serve the **full chain**: your leaf certificate followed by the intermediate certificate(s), in order, in a single file. Concatenate them leaf-first:
```bash
cat domain.crt intermediate.crt > fullchain.crt
```
Then point your web server at the combined file. In nginx:
```
ssl_certificate /etc/ssl/fullchain.crt;
ssl_certificate_key /etc/ssl/domain.key;
```
If you use Let's Encrypt, this is already done for you — always point `ssl_certificate` at `fullchain.pem`, not `cert.pem`.
After reloading the server, re-run the `openssl s_client` command above and confirm the full chain is now sent. Once the chain is complete, you can verify your overall configuration with the [SSLLabs test and aim for an A+ grade](/a-plus-grade-ssl-using-cloudflare).
### How to setup OpenClaw securely on your own VPS
Source: https://rocketeersapp.com/setup-openclaw-vps-securely
OpenClaw is a powerful open-source AI assistant that runs on your own infrastructure. Learn how to deploy it securely on a VPS to avoid common security pitfalls.
## What is OpenClaw?
[OpenClaw](https://openclaw.ai) is an open-source AI assistant that runs on your own infrastructure, giving you complete control over your data and deployment. It can connect to various messaging platforms including WhatsApp, Slack, Discord, Google Chat, Signal, and iMessage. OpenClaw can control browsers, generate videos and images, and even run scheduled tasks via cron.
Unlike cloud-based AI services, OpenClaw runs entirely on your server, making it an attractive option for privacy-conscious users and organizations that need to keep their data within their own infrastructure. For more details, check the [official OpenClaw documentation](https://docs.openclaw.ai/).
## Why security matters for OpenClaw
A recent Shodan scan revealed a concerning security landscape: **42,665 OpenClaw instances were found exposed to the public internet**, with 93.4% having authentication bypasses. Even more alarming, eight instances were completely open with no password, no token, and full shell access available to anyone who connected.
This makes security configuration absolutely critical when deploying OpenClaw. An improperly secured instance could give attackers complete access to your server and all connected services.
## Choosing a VPS provider
Several VPS providers offer streamlined OpenClaw deployment with varying levels of built-in security:
**Hetzner**
Hetzner offers excellent value with double the vCPUs and RAM at a fraction of the cost compared to other providers. However, like Hostinger, you'll need to implement all security measures yourself.
**DigitalOcean (Recommended for beginners)**
DigitalOcean offers a [1-Click Deploy](https://www.digitalocean.com/community/tutorials/how-to-run-openclaw) specifically designed for OpenClaw. This deployment automatically implements security best practices including:
- Authenticated communication via gateway tokens
- Hardened firewall rules that rate-limit OpenClaw ports
- Private DM pairing by default
The 1-Click Deploy handles much of the security configuration automatically, making it ideal for users who want a secure setup without extensive manual configuration.
## Essential security measures
Regardless of which provider you choose, implement these critical security measures:
**1. Use SSH key-based authentication**
Never use password authentication for SSH access. Generate and use SSH keys instead:
```bash
# On your local machine, generate an SSH key
ssh-keygen -t ed25519 -C "your_email@example.com"
# Copy the public key to your VPS
ssh-copy-id user@your-vps-ip
```
After confirming key-based authentication works, disable password authentication in `/etc/ssh/sshd_config`:
```bash
PasswordAuthentication no
PubkeyAuthentication yes
```
**2. Keep the gateway on loopback**
The OpenClaw gateway should never be directly exposed to the internet. Configure it to listen only on localhost (127.0.0.1) and access it through an SSH tunnel or Tailscale.
**Access via SSH tunnel:**
```bash
ssh -L 8080:localhost:8080 user@your-vps-ip
```
Then access OpenClaw locally at `http://localhost:8080`
**Access via Tailscale:**
Install [Tailscale](https://tailscale.com/) on your VPS and enable Tailscale Serve to securely expose OpenClaw only to devices on your private Tailscale network.
```bash
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
# Authenticate and connect
sudo tailscale up
# Serve OpenClaw securely
tailscale serve https / http://127.0.0.1:8080
```
**3. Require gateway authentication**
If you must bind OpenClaw to your LAN or Tailscale network, always require authentication. OpenClaw supports two authentication methods:
**Using a gateway token (recommended):**
```bash
# Set in your OpenClaw configuration
OPENCLAW_GATEWAY_TOKEN=your-secure-random-token-here
```
Generate a strong, random token and store it securely. You'll need this token to access the OpenClaw web interface.
**Using a password:**
```bash
# Alternative authentication method
OPENCLAW_GATEWAY_PASSWORD=your-strong-password-here
```
**4. Configure firewall rules**
Set up a firewall to restrict access to essential ports only:
```bash
# Install UFW (Uncomplicated Firewall)
sudo apt install ufw
# Deny all incoming traffic by default
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH (change 22 if using a custom port)
sudo ufw allow 22/tcp
# Enable the firewall
sudo ufw enable
# Check status
sudo ufw status
```
Do not open ports for OpenClaw gateway access. Instead, use SSH tunneling or Tailscale as described above.
**5. Keep software updated**
Regularly update both your system packages and OpenClaw itself:
```bash
# Update system packages
sudo apt update && sudo apt upgrade -y
# Update OpenClaw (run inside your OpenClaw directory)
git pull origin main
docker compose down
docker compose build --no-cache
docker compose up -d
# Check the latest release at: https://github.com/openclaw/openclaw/releases
```
## Installation and deployment
**Quick setup with Docker**
The easiest way to deploy OpenClaw is using Docker. Here's a secure setup process:
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Clone OpenClaw repository
git clone https://github.com/openclaw/openclaw.git
cd openclaw
# Create a secure .env file
cp .env.example .env
nano .env
```
In your `.env` file, configure these critical settings:
```bash
# Generate a strong random token
OPENCLAW_GATEWAY_TOKEN=$(openssl rand -hex 32)
# Bind only to localhost
OPENCLAW_GATEWAY_HOST=127.0.0.1
OPENCLAW_GATEWAY_PORT=8080
# Set your AI provider API keys securely
OPENAI_API_KEY=your-key-here
ANTHROPIC_API_KEY=your-key-here
```
Deploy OpenClaw:
```bash
# Build and start containers
docker compose build --no-cache
docker compose up -d
# Check logs
docker compose logs -f
```
**Securing API credentials**
Store all API credentials (OpenAI, Anthropic, etc.) in environment variables, never in code or configuration files that might be committed to version control.
Create a separate `.env` file that's excluded from git:
```bash
# Ensure .env is in .gitignore
echo ".env" >> .gitignore
```
Set appropriate file permissions:
```bash
chmod 600 .env
```
## Post-installation security checks
After installation, verify your security configuration:
**1. Check exposed ports**
```bash
sudo netstat -tulpn | grep LISTEN
```
You should only see SSH (port 22) and Docker internal services. The OpenClaw gateway should be listening on 127.0.0.1 only, not 0.0.0.0.
**2. Test authentication**
Try accessing the gateway without authentication to ensure it's properly protected:
```bash
curl http://localhost:8080
```
This should return an authentication error if properly configured.
**3. Review Docker container security**
```bash
# Check running containers
docker ps
# Review container logs for errors
docker compose logs --tail=100
```
## Maintenance and monitoring
**Regular security audits**
Schedule monthly security reviews:
- Check for unauthorized SSH access attempts: `sudo grep "Failed password" /var/log/auth.log`
- Review OpenClaw access logs for suspicious activity
- Update all dependencies and Docker images
- Verify firewall rules remain intact
**Automated backups**
Set up automated backups of your OpenClaw configuration and data:
```bash
# Create a backup script
cat > /root/backup-openclaw.sh << 'EOF'
#!/bin/bash
BACKUP_DIR="/backups/openclaw"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
cd /path/to/openclaw
# Backup configuration and data
tar -czf $BACKUP_DIR/openclaw_backup_$DATE.tar.gz \
.env \
docker-compose.yml \
data/
# Keep only last 30 days of backups
find $BACKUP_DIR -name "openclaw_backup_*.tar.gz" -mtime +30 -delete
EOF
chmod +x /root/backup-openclaw.sh
# Schedule daily backups via cron
(crontab -l 2>/dev/null; echo "0 2 * * * /root/backup-openclaw.sh") | crontab -
```
## Common security mistakes to avoid
1. **Exposing the gateway to 0.0.0.0** - Always bind to 127.0.0.1 and use tunneling
2. **Using weak or no authentication tokens** - Generate strong random tokens
3. **Running as root** - Create a dedicated user for OpenClaw
4. **Not updating regularly** - Set up automatic security updates
5. **Storing credentials in git** - Use environment variables and secure .env files
6. **Opening unnecessary firewall ports** - Only expose SSH, use tunnels for everything else
7. **Using default passwords** - Change all default credentials immediately
8. **Not monitoring logs** - Set up log monitoring and alerting
## Conclusion
OpenClaw is a powerful tool that requires careful security configuration. By following the practices outlined in this guide, you can deploy OpenClaw securely and avoid becoming part of the statistics of exposed instances.
Remember: security is not a one-time setup but an ongoing process. Regularly review your configuration, keep software updated, and monitor for suspicious activity.
The key principles are:
- Never expose OpenClaw directly to the internet
- Always require strong authentication
- Use SSH tunneling or Tailscale for access
- Keep software updated
- Monitor and audit regularly
With these measures in place, you can confidently run OpenClaw on your VPS while maintaining robust security.
### How to get A+ grade SSL using Cloudflare
Source: https://rocketeersapp.com/a-plus-grade-ssl-using-cloudflare
By default Cloudflare configures your security for SSL and HTTPS traffic for maximum connectivity and not for best security. Connectivity and security are unexchangeable, by letting more old insecure clients connect, you lower the bar for all clients that connect to your website.
To keep everything as secure as possible, it is advised to make use of the best new practices and to let go of old and crumbling technology. On the web security is always improving and therefore shifting away from older technologies that just don't make the cut anymore.
To analyze your HTTPS connection for your website, the golden standard for SSL configuration is the [SSL Server Test from SSLLabs](https://globalsign.ssllabs.com). This test gives your security configuration a grade and shows you if there are areas for improvement.
The highest grade is an A+ and to achieve this using Cloudflare, follow these easy steps:
## Getting started
Login to [Cloudflare](/providers/cloudflare) and navigate to the domain which you want to improve the SSL configuration for.
Navigate to the "SSL/TLS" section and then click on the "Edge Certificates" submenu.
## Enable Always Use HTTPS
Enable HTTPS for every visitor by enabling the "Always Use HTTPS" option.
## Enable HSTS
To make sure a browser can't connect using HTTP anymore and go directly into HTTPS mode, choose "Enable HSTS" to configure HTTP Strict Transport Security (HSTS)".
Acknowledge the notice that once this setting is enabled, you can't easily go back. So if your website or server does still need HTTP (yikes!) for some legacy URL, you have a problem. But the HTTP URL is already a problem on itself.
## Set minimum TLS version to 1.2
Scroll down until you see "Minimum TLS Version" and select "1.2" as the minimum version clients can use to connect to your website. This skips unsecure versions 1.0 and 1.1 of TLS. At some point it should be feasible to add 1.3 as the minimum version, but nowadays there are too many clients still compatible with 1.2 only.
## Make sure to enable "TLS 1.3"
Enable TLS 1.3 by making sure the toggle is switched on, this way the newest version of TLS can be used by all clients.
## Fix legacy HTTP URLs automatically
This one is not necessarily needed for an A+ grade on SSLLabs, but enabling "Automatic HTTPS Rewrites" makes sure your websites does not follow or uses any HTTP references anymore. Everything should be HTTPS, to make sure no mixed content is used on your website.
You're done! Now run that SSLLabs test and get your A+ grade. For a quick second opinion you can also run our free [SSL checker](/ssl) and [HSTS checker](/hsts) against your domain.
## Common SSL errors after changing settings
If something breaks after tightening your SSL, a few errors come up again and again. Setting the Cloudflare SSL mode incorrectly (Flexible instead of Full) is the classic cause of [ERR_TOO_MANY_REDIRECTS](/err-too-many-redirects). A visitor-facing warning like [your connection is not private](/your-connection-is-not-private) or [NET::ERR_CERT_AUTHORITY_INVALID](/net-err-cert-authority-invalid) usually points at an expired certificate or a missing certificate chain.
### How to optimize web application security
Source: https://rocketeersapp.com/optimize-web-application-security
There are a lot of features you can use to increase overall security of your website or web app.
## HTTPS
The web server can serve your web application over a secured HTTPS connection using a signed SSL certificate. In the earlier days of the internet is wasn't that common like it is today. Make sure your redirecting all your normal HTTP traffic to HTTPS by default, so that every request to your web application gets automatically upgraded and secure. In addition, [HSTS](#http-strict-transport-security-hsts) can help with this.
## HSTS
The HTTP Strict-Transport-Security (HSTS) response header informs the browser that the site should only be accessed using HTTPS, and that any future attempts to access it using HTTP should automatically be converted to HTTPS. This setting can be defined at server and web application level using HTTP headers. The setting can be cached heavily by the browser and therefore make your website inaccessible when the HTTPS connection is not configured correctly.
## CSRF
Cross-Site Request Forgery (CSRF) is a protection mechanism for preventing requests being made from outside your application. Typically it is used to prevent all non-GET requests. For possibly destructive actions like POST, PUT and DELETE there is a verification performed by your application that checks a randomly generated token that is attached to the session of the user. Because only the server can retrieve the token correctly and send it with any non-GET request, then the application can be sure that the request is coming from the application itself.
## CORS
Cross-Origin Resource Sharing (CORS) is a protection layer configured in your web server or web application using an HTTP-header that defines an origin other than its own to permit the browser to load resources from. By default browsers block loading resources from external domains using Javascript.
[Read more about CORS on MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
## DNSSEC
Domain Name System Security Extensions (DNSSEC) is a protection layer on top of DNS. It makes sure that the DNS server that is responding to the HTTP client (for example a web browser) is authenticated by the domain authority that keeps a register of the domain in the registry. Therefore the DNSSEC needs to be implemented on the domain registry level and contains settings that need to match the settings in the DNS.
[Read more about DNSSEC on SIDN](https://www.sidn.nl/en/modern-internet-standards/dnssec)
## DANE
DNS-based Authentication of Named Entities (DANE) is a protocol that only works when DNSSEC is activated.
## CSP
[Content Security Policy (CSP)](/content-security-policy) is a protection layer configured in your web server or web application using an HTTP-header that defines what resources are allowed to be loaded by the browser. This can be used to prevent loading resources from external domains or to prevent loading resources that are not using HTTPS.
## Nonce
A nonce is a randomly generated token that should be used only one time for one request to your web application and can be used by Content-Security Policy (CSP).
The nonce can be generated by the web server or the web application and is being sent as HTTP header and inside the HTML. By adding a `nonce` attribute to `