<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/vendor/feed/atom.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
                        <id>https://rocketeersapp.com/feed.xml</id>
                                <link href="https://rocketeersapp.com/feed.xml" rel="self"></link>
                                <title><![CDATA[Rocketeers]]></title>
                    
                                <subtitle>All published articles from Rocketeers.</subtitle>
                                                    <updated>2026-06-30T00:00:00+00:00</updated>
                        <entry>
            <title><![CDATA[Enable JIT in PHP 8.x OPcache]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/php-8-opcache-jit" />
            <id>https://rocketeersapp.com/php-8-opcache-jit</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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
```]]>
            </summary>
                                    <updated>2024-11-21T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Detect Googlebot visits using nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/detect-googlebot-nginx" />
            <id>https://rocketeersapp.com/detect-googlebot-nginx</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2024-11-18T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Use nginx try_files to make your site static]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/nginx-try-files" />
            <id>https://rocketeersapp.com/nginx-try-files</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2024-11-18T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Log bot requests in nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/log-bot-requests-nginx" />
            <id>https://rocketeersapp.com/log-bot-requests-nginx</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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;
```]]>
            </summary>
                                    <updated>2024-11-18T15:01:08+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Improving PHP performance]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/php-performance" />
            <id>https://rocketeersapp.com/php-performance</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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
```]]>
            </summary>
                                    <updated>2024-11-21T11:15:44+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[What is Cloudflare?]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/what-is-cloudflare" />
            <id>https://rocketeersapp.com/what-is-cloudflare</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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 and keeps the IP ranges updated on your servers automatically, so the proxy works correctly without manual upkeep.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to host your own website]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/how-to-host-your-own-website" />
            <id>https://rocketeersapp.com/how-to-host-your-own-website</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA["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](/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. A backup you've never restored isn't a backup.
- **Monitoring** — 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.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Tools to validate your website]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/tools-to-validate-website" />
            <id>https://rocketeersapp.com/tools-to-validate-website</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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+; 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.
- **[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](/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 after launch, so you spend your time fixing the few things left rather than the basics.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to install multiple PHP versions on the same server]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/how-to-install-multiple-php-versions-on-same-server" />
            <id>https://rocketeersapp.com/how-to-install-multiple-php-versions-on-same-server</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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-unnecessary-and-unused-php-versions-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 — the FPM socket wiring and the cleanup happen for you.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to install Certbot]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/how-to-install-certbot" />
            <id>https://rocketeersapp.com/how-to-install-certbot</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to install Nginx on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/how-to-install-nginx" />
            <id>https://rocketeersapp.com/how-to-install-nginx</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[[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 and generates a correct, tested vhost for every site you deploy — so you never hand-edit a config file or reload a broken one.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to install Node.js on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/how-to-install-nodejs" />
            <id>https://rocketeersapp.com/how-to-install-nodejs</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[Even a PHP application usually needs Node.js — to compile CSS, bundle JavaScript with Vite or webpack, and run the asset build step during [deployment](/zero-downtime-php-deployments). The cleanest way to install it is [nvm](https://github.com/nvm-sh/nvm), the Node Version Manager, which keeps Node in your home directory so different projects can run different versions without `sudo`.

## Install nvm

Run the official install script (pin it to a specific nvm version so the install is reproducible):

```bash
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash
```

The script appends the loader to your shell profile. Either open a new shell or load it into the current one:

```bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
```

Confirm it's available:

```bash
nvm --version
```

## Install Node.js

Install the version you want and set it as the default for new shells:

```bash
nvm install 22
nvm alias default 22
```

Check both Node and npm came along:

```bash
node -v
npm -v
```

## Switch versions per project

The whole point of nvm is that one server can run several Node versions. Install another and switch with a single command:

```bash
nvm install 20
nvm use 20
```

Drop a `.nvmrc` file containing a version number (for example `22`) in a project's root and `nvm use` will pick it up automatically — handy for keeping a deploy script on the right version.

## Building assets on deploy

In a deployment you'll typically install dependencies and run a build:

```bash
npm ci
npm run build
```

Use `npm ci` rather than `npm install` on a server — it's faster and installs exactly what's in the lockfile. The difference matters more than it looks; see [npm ci vs npm install](/npm-ci-vs-npm-install).

## The alternative: NodeSource

If you'd rather install Node system-wide (every user, no per-shell loading), the NodeSource apt repository is the common alternative. nvm wins when you need multiple versions or want to avoid `sudo`; NodeSource wins for a single fixed version on a dedicated box.

## Let Rocketeers handle it

Installing nvm, loading it in the right shell profile, pinning versions per project, and making sure the deploy step runs with the correct Node version is one more moving part to keep aligned. Rocketeers installs Node.js during provisioning and runs your asset build as part of every deployment — so the frontend is compiled and live without a separate setup.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Website launch checklist]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/website-checklist" />
            <id>https://rocketeersapp.com/website-checklist</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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 — 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](/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 for anything users upload.
- [ ] Uptime and error monitoring 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.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Why do you need Cloudflare?]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/why-do-you-need-cloudflare" />
            <id>https://rocketeersapp.com/why-do-you-need-cloudflare</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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](/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, 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.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to install MySQL on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/how-to-install-mysql" />
            <id>https://rocketeersapp.com/how-to-install-mysql</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[Ubuntu ships MySQL in its repositories, but it's often an older release. To run a current, supported MySQL, install it from MySQL's own apt repository. Here's the full path from a fresh server to a database your application can connect to.

## Add the official MySQL repository

Import MySQL's signing key and add the repository for the current LTS release:

```bash
sudo apt-get install -y dirmngr gnupg
echo "deb http://repo.mysql.com/apt/ubuntu $(lsb_release -sc) mysql-8.0-lts" \
  | sudo tee /etc/apt/sources.list.d/mysql.list
```

Pin it so apt prefers MySQL's packages over Ubuntu's:

```bash
printf 'Package: mysql*\nPin: origin repo.mysql.com\nPin-Priority: 1001\n' \
  | sudo tee /etc/apt/preferences.d/mysql
```

## Install MySQL

```bash
DEBIAN_FRONTEND=noninteractive sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y mysql-server mysql-client
```

Start it and enable it on boot:

```bash
sudo systemctl enable --now mysql
```

## Secure the installation

A fresh MySQL has test databases, anonymous access, and a passwordless root. Lock it down:

```bash
sudo mysql_secure_installation
```

Set a strong root password, remove the anonymous users, disallow remote root login, and drop the test database when prompted.

## Create a database and user

Never let your application connect as root. Open a MySQL prompt:

```bash
sudo mysql
```

Then create a dedicated database and user with privileges only on that database:

```sql
CREATE DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'myapp'@'localhost' IDENTIFIED BY 'a-strong-password';
GRANT ALL PRIVILEGES ON `myapp`.* TO 'myapp'@'localhost';
FLUSH PRIVILEGES;
```

If you hit `ERROR 1045 Access denied`, the username, password, or host doesn't match what you granted — see [access denied for user](/sqlstate-hy000-1045-access-denied-for-user).

## Allow remote connections (only if you need them)

By default MySQL listens on `localhost` only, which is the safest setting when the database and app share a server. If a separate application server needs access, bind to all interfaces in a config file:

```ini
# /etc/mysql/conf.d/networking.cnf
[mysqld]
bind-address = 0.0.0.0
```

Then create the user with the `'%'` host and restart MySQL:

```sql
CREATE USER 'myapp'@'%' IDENTIFIED BY 'a-strong-password';
GRANT ALL PRIVILEGES ON `myapp`.* TO 'myapp'@'%';
FLUSH PRIVILEGES;
```

Only do this behind a firewall that restricts which IPs can reach port 3306. An open MySQL port is one of the fastest ways to get a server compromised.

## Next steps

Now you can [import a database from the command line](/import-database-mysql-command-line), [export one](/export-database-mysql-command-line), and start thinking about [backups](/backup-mysql-databases-single-file) and [performance tuning](/optimize-mysql-performance). On a busy server you'll eventually meet [too many connections](/mysql-1040-too-many-connections) — worth knowing about before it happens.

## Let Rocketeers handle it

Adding the right repository, pinning packages, securing the defaults, raising file-descriptor limits, and creating databases and scoped users by hand is a lot of careful steps to get exactly right — and easy to leave half-done. Rocketeers installs and secures MySQL during provisioning and creates databases and users on demand, with credentials stored safely, so you skip straight to using the database.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to renew SSL certificates automatically]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/renew-ssl-certificates-automatically" />
            <id>https://rocketeersapp.com/renew-ssl-certificates-automatically</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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).]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to install PHP on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/how-to-install-php" />
            <id>https://rocketeersapp.com/how-to-install-php</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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).

## 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 version — no PPAs or socket paths to remember.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Important PHP config options]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/important-php-config-options" />
            <id>https://rocketeersapp.com/important-php-config-options</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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 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.]]>
            </summary>
                                    <updated>2026-06-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Setting process priority for Laravel Horizon workers]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/laravel-horizon-nice-process-priority" />
            <id>https://rocketeersapp.com/laravel-horizon-nice-process-priority</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-24T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[403 Forbidden in nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/403-forbidden-nginx" />
            <id>https://rocketeersapp.com/403-forbidden-nginx</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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
```]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to enable and configure OPcache for faster PHP]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/enable-opcache-php" />
            <id>https://rocketeersapp.com/enable-opcache-php</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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
<?php
var_dump(opcache_get_status());
```

Look at `opcache_enabled`, `memory_usage`, and `opcache_statistics.opcache_hit_rate`. A healthy production server sits well above 95% hits.

## Enable OPcache in php.ini

If it isn't on, enable it in your `php.ini`. On Ubuntu the FPM config usually lives at `/etc/php/8.3/fpm/php.ini` (adjust the version):

```ini
opcache.enable=1
opcache.enable_cli=0
```

Leave `opcache.enable_cli` off unless you specifically run long-lived CLI workers; for normal artisan and composer commands it just wastes memory caching one-off scripts.

## Key production settings

The defaults are conservative. These four settings matter most:

```ini
; Shared memory for compiled bytecode, in MB
opcache.memory_consumption=256

; Memory for interned (deduplicated) strings, in MB
opcache.interned_strings_buffer=16

; Max number of files OPcache will cache; set above your file count
opcache.max_accelerated_files=20000

; Don't check the filesystem for changes on every request (production)
opcache.validate_timestamps=0
```

- **`opcache.memory_consumption`** — total shared memory for cached bytecode. 128–256 MB is comfortable for most apps; if it fills up, OPcache starts evicting and your hit rate drops.
- **`opcache.interned_strings_buffer`** — PHP stores each unique string once. Large frameworks use a lot, so 16 MB is a sensible bump from the default 8.
- **`opcache.max_accelerated_files`** — the cap on cached files. Count your files with `find . -type f -name '*.php' | wc -l` and set this comfortably higher. OPcache rounds up to the next prime.
- **`opcache.validate_timestamps`** — when `0`, OPcache never checks if a file changed on disk, which is fastest and correct for production. The trade-off is covered below.

### Development settings

In development you want changes to show up without a restart. Keep timestamp validation on and check periodically instead of on every request:

```ini
opcache.validate_timestamps=1
opcache.revalidate_freq=2
```

`opcache.revalidate_freq=2` means OPcache re-checks a file's mtime at most every 2 seconds, a good balance between freshness and overhead during local work.

## Important: reset OPcache on every deploy

With `opcache.validate_timestamps=0`, OPcache will happily serve the *old* bytecode forever, even after you deploy new code, because it never looks at the files again. You must explicitly clear the cache as part of your deploy.

The cleanest option is to reload PHP-FPM, which drops the cache:

```bash
sudo systemctl reload php8.3-fpm
```

If you'd rather not reload the service, call `opcache_reset()` from a web request (it has no effect from CLI when FPM holds the cache), or use a tool like `cachetool`:

```bash
cachetool opcache:reset --fcgi=/run/php/php8.3-fpm.sock
```

Skipping this step is the single most common OPcache mistake: a deploy "doesn't take" because the server is still running cached bytecode.

## Restart PHP-FPM to apply changes

Editing `php.ini` doesn't do anything until PHP-FPM reloads it:

```bash
sudo systemctl reload php8.3-fpm
# verify the new settings
php-fpm8.3 -i | grep opcache.memory_consumption
```

## JIT on PHP 8

PHP 8 added a JIT compiler that lives inside OPcache and can compile hot paths to native machine code. It helps CPU-bound workloads more than typical web requests, and it has its own configuration. See [enabling the PHP 8 OPcache JIT](/php-8-opcache-jit) for the details rather than turning it on blindly here.

## Conclusion

Enabling OPcache is the highest-leverage, lowest-effort PHP performance change you can make: turn it on, give it enough memory, set `validate_timestamps=0` in production, and remember to reset it on every deploy. From there, look at [broader PHP performance tuning](/php-performance) and, on Laravel, [Laravel-specific optimizations](/laravel-performance) to keep squeezing out latency.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[A complete guide to caching in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/laravel-cache" />
            <id>https://rocketeersapp.com/laravel-cache</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to send GET and POST requests with curl]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/curl-post-get-api-requests" />
            <id>https://rocketeersapp.com/curl-post-get-api-requests</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Talking to APIs from the terminal

When I'm building or debugging an API, `curl` is the first tool I reach for. It lets me fire requests straight from the terminal without opening a browser or a GUI client. Here's how I use it day to day. For the full reference, see my [complete curl guide](/curl-command-complete-guide).

## GET requests

A GET request is the default, so you only need the URL:

```bash
curl https://api.example.com/users
```

If you need query parameters, just append them to the URL. I like to use `-G` together with `--data-urlencode` so curl handles the encoding for me:

```bash
curl -G https://api.example.com/users \
  --data-urlencode "search=jane doe" \
  --data-urlencode "page=2"
```

## POST requests

To send a POST, use `-X POST` and pass a body with `-d` (or `--data`). The presence of `-d` already implies POST, but I keep `-X POST` for clarity:

```bash
curl -X POST https://api.example.com/users \
  -d "name=Jane" \
  -d "email=jane@example.com"
```

That sends data as `application/x-www-form-urlencoded`, which is the classic HTML form encoding.

## Sending JSON

Most modern APIs expect JSON. Set the `Content-Type` header with `-H` and pass the body with `-d`:

```bash
curl -X POST https://api.example.com/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Jane", "email": "jane@example.com"}'
```

I wrap the JSON in single quotes so the double quotes inside survive the shell. For larger payloads, read the body from a file with `-d @payload.json`.

## Auth headers and bearer tokens

Authentication is almost always a header. Add as many `-H` flags as you need:

```bash
curl https://api.example.com/me \
  -H "Authorization: Bearer eyJhbGciOi..." \
  -H "Accept: application/json"
```

For HTTP basic auth there's a shortcut, `-u user:password`, which builds the header for you:

```bash
curl -u admin:secret https://api.example.com/admin
```

## Inspecting the response

When something isn't behaving, I want to see the status code and headers. Use `-i` to include response headers in the output:

```bash
curl -i https://api.example.com/users
```

For the full story, including the request curl sent and the TLS handshake, use `-v` (verbose):

```bash
curl -v https://api.example.com/users
```

If `-v` shows a certificate error, I've written up the fix for the most common one: [curl error 60: SSL certificate problem](/curl-60-ssl-certificate-problem-unable-to-get-local-issuer-certificate).

## Saving the response

To write the body to a file instead of the terminal, use `-o` with a filename, or `-O` to reuse the remote filename:

```bash
curl -o response.json https://api.example.com/users
```

That's enough to test almost any HTTP API. Once you're comfortable with `-X`, `-H`, `-d`, and `-i`/`-v`, the rest is just combining them.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[ERR_TOO_MANY_REDIRECTS (redirect loop)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/err-too-many-redirects" />
            <id>https://rocketeersapp.com/err-too-many-redirects</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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, 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
}
```

### Diagnose the loop

Follow the redirect chain from the command line 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[git stash pop vs apply: save and restore changes]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/git-stash-pop" />
            <id>https://rocketeersapp.com/git-stash-pop</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Stashing your changes

`git stash` takes your modified tracked files, saves them away, and gives you a clean working directory:

```bash
git stash
```

By default it does **not** include untracked files. Add `-u` to stash those too:

```bash
git stash -u
```

Give a stash a label so you can recognise it later:

```bash
git stash push -m "half-finished login form"
```

## pop vs apply

Both bring your changes back. The difference is what happens to the stash afterwards:

```bash
git stash pop     # restore the changes AND delete the stash entry
git stash apply   # restore the changes but KEEP the stash entry
```

Use `apply` when you want to restore the same changes onto more than one branch. Use `pop` for the normal "I'm back, give me my work" case.

## Working with multiple stashes

List what you have stashed:

```bash
git stash list
```

```text
stash@{0}: On main: half-finished login form
stash@{1}: WIP on feature: 3f9a1c2 ...
```

Apply or pop a specific one by its reference:

```bash
git stash apply stash@{1}
git stash pop stash@{1}
```

Drop a stash you no longer need, or clear them all:

```bash
git stash drop stash@{0}
git stash clear
```

## When pop hits a conflict

If your stashed changes conflict with the current state of the files, `git stash pop` stops with merge conflicts and, importantly, **keeps the stash** so you do not lose it. Resolve the conflicts, stage the files, then drop the stash manually:

```bash
git stash drop
```

If you would rather permanently discard uncommitted changes than stash them, see [git reset --hard](/git-reset-hard).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Address already in use (port already bound)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/address-already-in-use-port" />
            <id>https://rocketeersapp.com/address-already-in-use-port</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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 <pid>
# if it ignores that:
kill -9 <pid>
```

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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[npm ci vs npm install: when to use which]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/npm-ci-vs-npm-install" />
            <id>https://rocketeersapp.com/npm-ci-vs-npm-install</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## What npm install does

`npm install` is the everyday command for adding and updating dependencies:

```bash
npm install
npm install lodash       # add a package
```

It reads `package.json`, resolves versions, installs them, and **writes** the result to `package-lock.json`. If a dependency allows a newer version within its range, the lockfile can change. That flexibility is exactly what you want while developing.

## What npm ci does

`npm ci` (clean install) is built for automated and reproducible installs:

```bash
npm ci
```

It behaves differently in three important ways:

- It installs **exactly** what is in `package-lock.json`, ignoring version ranges in `package.json`.
- It **deletes** `node_modules` first, so you always start from a clean slate.
- It **never writes** to `package.json` or `package-lock.json`.

It is also typically faster than `npm install` because it skips dependency resolution.

## The catch: the lockfile must exist and match

`npm ci` requires a `package-lock.json` (or `npm-shrinkwrap.json`), and that lockfile must be in sync with `package.json`. If they disagree, it fails on purpose rather than silently "fixing" things:

```text
npm ci can only install packages when your package.json and
package-lock.json are in sync. ...
```

The fix is to run `npm install` locally, commit the updated lockfile, and try again.

## When to use which

| Situation | Use |
|---|---|
| Day-to-day development | `npm install` |
| Adding or upgrading a package | `npm install <pkg>` |
| CI / CD pipelines | `npm ci` |
| Production / Docker builds | `npm ci` |

The rule of thumb: if you want reproducible, lockfile-exact installs, use `npm ci`. If you intend to change your dependencies, use `npm install`. Because `npm ci` wipes `node_modules`, it also clears up the kind of half-installed state that otherwise has people deleting the folder by hand.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to generate a CSR with OpenSSL]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/generate-csr-with-openssl" />
            <id>https://rocketeersapp.com/generate-csr-with-openssl</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to delete a local (and remote) Git branch]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/git-delete-local-branch" />
            <id>https://rocketeersapp.com/git-delete-local-branch</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Delete a local branch

Use `-d` (lowercase) to delete a branch that has already been merged. Git refuses if the branch has unmerged commits, which protects you from losing work:

```bash
git branch -d feature-login
```

If you are sure you want to discard the branch even though it is not merged, force it with `-D`:

```bash
git branch -D feature-login
```

You cannot delete the branch you are currently on. Switch away first:

```bash
git switch main
git branch -d feature-login
```

## Delete the remote branch

Removing the branch locally does not touch the remote. Delete it there explicitly:

```bash
git push origin --delete feature-login
```

An older shorthand does the same thing (note the leading colon):

```bash
git push origin :feature-login
```

## Clean up stale remote-tracking branches

After a branch is deleted on the remote, your local clone may still show it under `git branch -r`. Prune those references:

```bash
git fetch --prune
```

To rename a branch instead of deleting it, see [How to rename a Git branch](/git-rename-branch).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[CORS error: No Access-Control-Allow-Origin header]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/cors-error-no-access-control-allow-origin" />
            <id>https://rocketeersapp.com/cors-error-no-access-control-allow-origin</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[No application encryption key has been specified]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/no-application-encryption-key-has-been-specified" />
            <id>https://rocketeersapp.com/no-application-encryption-key-has-been-specified</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[MySQL max_allowed_packet: packet too large]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/mysql-max-allowed-packet-packet-too-large" />
            <id>https://rocketeersapp.com/mysql-max-allowed-packet-packet-too-large</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

You'll hit one of these:

```bash
ERROR 1153 (08S01): Got a packet bigger than 'max_allowed_packet' bytes
ERROR 2006 (HY000): MySQL server has gone away
```

When a client or the server receives a single packet larger than `max_allowed_packet`, it rejects it and drops the connection, which often surfaces as the misleading "server has gone away" message.

## Why do I see this error

- Importing a database dump that contains very large multi-row `INSERT` statements.
- Storing large `BLOB` / `TEXT` values, like images or big JSON columns.
- A bulk insert that builds one enormous query.

## Solution

### Raise the limit for the running server

You can set it at runtime without a restart (the value is in bytes, this is 256 MB):

```sql
SET GLOBAL max_allowed_packet = 268435456;
```

A runtime change is reset on restart, so make it permanent in `my.cnf` (`/etc/mysql/my.cnf` or a file under `/etc/mysql/conf.d/`):

```ini
[mysqld]
max_allowed_packet = 256M
```

Then restart MySQL:

```bash
systemctl restart mysql
```

### Set it for an import

When the error happens during an import, the **client** also has its own limit. Pass it on the command line so both sides agree:

```bash
mysql --max_allowed_packet=256M -u forge -p my_app < dump.sql
```

See [importing a database from the command line](/import-database-mysql-command-line) for the full import workflow.

### Check the current value

To see where it's set right now:

```sql
SHOW VARIABLES LIKE 'max_allowed_packet';
```

If you regularly export and re-import large databases, it's worth setting a generous value on both the source and destination so dumps move cleanly. See [exporting a database from the command line](/export-database-mysql-command-line).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How database indexing works (with MySQL examples)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/database-indexing" />
            <id>https://rocketeersapp.com/database-indexing</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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.

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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[curl (60) SSL certificate problem: unable to get local issuer certificate]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/curl-60-ssl-certificate-problem-unable-to-get-local-issuer-certificate" />
            <id>https://rocketeersapp.com/curl-60-ssl-certificate-problem-unable-to-get-local-issuer-certificate</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Essential Linux command line basics for developers]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/linux-command-line-basics" />
            <id>https://rocketeersapp.com/linux-command-line-basics</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Why the command line is worth it

If you write code or touch a server, the terminal is unavoidable, and honestly that's a good thing. It's faster than clicking, it's scriptable, and it's the same on your laptop and on a remote box. This is the set of commands I'd hand a new developer to get comfortable. Everything here works on Ubuntu and macOS alike.

## Knowing where you are and moving around

The terminal always has a "current directory". Three commands cover navigation:

```bash
pwd        # print working directory: where am I right now?
ls         # list files in the current directory
cd /var/www  # change directory
```

`ls` becomes much more useful with flags:

```bash
ls -l      # long format: permissions, owner, size, date
ls -la     # also show hidden files (the ones starting with a dot)
ls -lh     # human-readable sizes (KB, MB instead of bytes)
```

A few `cd` shortcuts save constant typing: `cd ~` goes home, `cd ..` goes up one level, and `cd -` jumps back to the previous directory.

## Working with files and directories

The everyday file commands:

```bash
mkdir project          # make a directory
touch project/app.js   # create an empty file (or update its timestamp)
cp app.js app.bak.js   # copy a file
mv app.bak.js backup/  # move or rename a file
rm app.bak.js          # remove a file
```

Be careful with `rm`. There is no recycle bin. To delete a directory and everything in it you need `rm -r directory`, and that's exactly the command people regret. Double-check the path before you hit Enter.

To read files without opening an editor:

```bash
cat app.js     # dump the whole file to the screen
less app.js    # scroll through a long file (q to quit)
head -n 20 app.js   # first 20 lines
tail -n 20 app.js   # last 20 lines
tail -f app.log     # follow a log file live
```

## Pipes and redirection

This is where the command line gets powerful. The pipe `|` sends one command's output into another command's input:

```bash
ls -l | less          # page through a long listing
cat app.log | grep ERROR   # show only lines containing ERROR
```

Redirection sends output to a file instead of the screen:

```bash
echo "hello" > notes.txt    # write to a file (OVERWRITES it)
echo "more" >> notes.txt    # append to a file (keeps existing content)
```

The difference between `>` and `>>` matters: a single `>` wipes the file first. Get them mixed up and you'll lose the contents of a file you meant to add to.

## A quick word on permissions

Run `ls -l` and you'll see strings like `-rwxr-xr--` at the start of each line. Those are permissions: read (`r`), write (`w`), and execute (`x`), shown for the owner, the group, and everyone else. When a script "won't run" or you get a "permission denied", this is usually why. The full story, including `chmod` and the numbers like `755`, is in the [change file permissions with chmod guide](/change-file-permissions-chmod-linux).

## Where to go next

These basics get you around, but the real productivity comes from a handful of focused tools:

- Finding files by name or type: [find files on the Linux command line](/find-files-linux-command).
- Searching inside files for text: [search files with grep](/search-files-grep-command).
- Bundling and compressing directories: [create and extract tar archives](/create-extract-tar-archives-linux).
- Working on a remote machine: [connect to a server with SSH](/connect-to-server-ssh-command).

Pick one command, use it for real on something you're actually doing, and it sticks. That beats memorizing a cheat sheet every time.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[NET::ERR_CERT_AUTHORITY_INVALID]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/net-err-cert-authority-invalid" />
            <id>https://rocketeersapp.com/net-err-cert-authority-invalid</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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** (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.

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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[What is an SSH key]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/what-is-an-ssh-key" />
            <id>https://rocketeersapp.com/what-is-an-ssh-key</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[nginx rewrite or internal redirection cycle]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/nginx-rewrite-or-internal-redirection-cycle" />
            <id>https://rocketeersapp.com/nginx-rewrite-or-internal-redirection-cycle</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[docker compose up: options and common flags]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/docker-compose-up" />
            <id>https://rocketeersapp.com/docker-compose-up</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Start your services

Run from the directory containing your `compose.yaml` (or `docker-compose.yml`):

```bash
docker compose up
```

This creates and starts every service defined in the file, and streams their logs to your terminal. Press `Ctrl+C` to stop them.

## Run in the background

Most of the time you want the stack running detached, so your terminal is free:

```bash
docker compose up -d
```

Check what is running and follow the logs separately:

```bash
docker compose ps
docker compose logs -f
```

## Rebuild images first

If you changed a `Dockerfile` or the build context, force a rebuild before starting:

```bash
docker compose up -d --build
```

To throw away cached containers and recreate them from scratch:

```bash
docker compose up -d --force-recreate
```

## Start only some services

Pass service names to start a subset. Add `--no-deps` if you do not want its linked services started too:

```bash
docker compose up -d web
docker compose up -d --no-deps web
```

## Stopping again

`Ctrl+C` stops a foreground `up`. For a detached stack, bring it down explicitly. `down` also removes the containers and network; `stop` just halts them:

```bash
docker compose down       # stop and remove containers + network
docker compose stop       # stop but keep the containers
```

## A note on the command name

Modern Docker uses `docker compose` (a built-in subcommand, Compose v2). The older standalone tool was `docker-compose` with a hyphen. If `docker compose` is not found, you are likely on an old install, upgrade Docker, or fall back to `docker-compose`.

Once it is up, [docker exec](/docker-exec) lets you run commands inside a running service, and [docker prune](/docker-prune) cleans up afterwards.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to search file contents with grep]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/search-files-grep-command" />
            <id>https://rocketeersapp.com/search-files-grep-command</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Searching inside files with grep

While [find](/find-files-linux-command) locates files by their name or metadata, `grep` searches what's *inside* them. It scans for lines matching a pattern and prints them. The basic form is `grep 'pattern' file`:

```bash
grep 'database' config/app.php
```

## Search recursively

To search every file under a directory, add `-r` (recursive). This is the one I use most:

```bash
grep -r 'API_KEY' .
```

## Case-insensitive matching

By default grep is case-sensitive. Add `-i` to ignore case, so `Error`, `error` and `ERROR` all match:

```bash
grep -ri 'todo' src/
```

## Show line numbers

Add `-n` to prefix each match with its line number, which makes jumping to it in an editor much faster:

```bash
grep -n 'function' app.js
```

## Match whole words and invert

Use `-w` to match a whole word only, so searching `log` won't also match `login` or `catalog`:

```bash
grep -w 'log' app.php
```

Flip the logic with `-v` to print lines that *don't* match — useful for filtering noise out of a file:

```bash
grep -v '^#' config.ini
```

That example drops every commented line (those starting with `#`).

## Count matches

When you only care how many times something appears, `-c` prints the count of matching lines instead of the lines themselves:

```bash
grep -c 'POST' access.log
```

## Show surrounding context

Sometimes the matching line alone isn't enough. Use `-A` for lines after, `-B` for before, and `-C` for both:

```bash
grep -C 3 'Exception' storage/logs/laravel.log
```

This prints each match with 3 lines on either side.

## Using regular expressions

The pattern is a regular expression, so you can match more than literal text. Add `-E` for extended regex syntax like alternation and `+`:

```bash
grep -E 'warning|error|critical' app.log
```

This finds any line containing one of those three words.

## Piping into grep

grep really shines at the end of a pipe, filtering the output of another command. This is everyday glue on the command line:

```bash
ps aux | grep nginx
git log --oneline | grep 'fix'
```

Combine these flags freely — for example `grep -rin 'token' .` gives you a recursive, case-insensitive search with line numbers — and grep becomes one of the most useful tools in your kit.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[SSL handshake failed in nginx (ERR_SSL_PROTOCOL_ERROR)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/ssl-handshake-failed-nginx" />
            <id>https://rocketeersapp.com/ssl-handshake-failed-nginx</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[PHP file upload exceeds upload_max_filesize]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/php-file-upload-exceeds-upload-max-filesize" />
            <id>https://rocketeersapp.com/php-file-upload-exceeds-upload-max-filesize</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Redis connection refused in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/redis-connection-refused-laravel" />
            <id>https://rocketeersapp.com/redis-connection-refused-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[MySQL 1071: Specified key was too long (Laravel migration)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/mysql-1071-specified-key-was-too-long" />
            <id>https://rocketeersapp.com/mysql-1071-specified-key-was-too-long</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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();
```]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[The complete guide to the curl command]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/curl-command-complete-guide" />
            <id>https://rocketeersapp.com/curl-command-complete-guide</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## What curl is

`curl` is the Swiss Army knife for talking to anything over a URL from your terminal. HTTP, HTTPS, FTP, and more. I reach for it constantly: testing an API, downloading a file, checking what headers a server returns, debugging a redirect. It's installed by default on macOS and almost every Linux distro. This guide is the reference I wish I'd had when I started.

## The basic GET request

With no flags at all, curl fetches a URL and prints the body to your terminal:

```bash
curl https://example.com
```

That's a plain GET request. For working with JSON APIs specifically (sending data, parsing responses), I've got a focused walkthrough in [curl POST and GET API requests](/curl-post-get-api-requests). This guide covers the whole toolbox.

## Downloading files

To save the response instead of printing it, use `-o` (lowercase) with a filename, or `-O` (uppercase) to keep the remote filename:

```bash
curl -o latest.tar.gz https://example.com/releases/v2.tar.gz
curl -O https://example.com/releases/v2.tar.gz
```

Add `-#` for a progress bar, or `-s` to silence the progress meter entirely (handy in scripts).

## Following redirects

By default, curl does **not** follow redirects. If a URL returns a 301 or 302, you'll just see the redirect response, not the final page. Add `-L` to follow it:

```bash
curl -L https://example.com
```

This trips people up constantly when a download "comes back empty". It's almost always a redirect that curl didn't follow. When in doubt, add `-L`.

## Sending headers

Use `-H` to add request headers. You can repeat it as many times as you need:

```bash
curl -H "Accept: application/json" \
     -H "Authorization: Bearer YOUR_TOKEN" \
     https://api.example.com/user
```

## Choosing the HTTP method

`-X` sets the method. GET is the default, so you mostly need this for `POST`, `PUT`, `DELETE`, and friends:

```bash
curl -X DELETE https://api.example.com/posts/42
```

Note that when you send data with `-d` (next section), curl switches to POST automatically, so you don't always need `-X POST`.

## Sending data

`-d` sends a request body. This implies POST and a form content type:

```bash
curl -d "name=ada&role=admin" https://api.example.com/users
```

For JSON, set the header explicitly and pass a JSON string:

```bash
curl -X POST https://api.example.com/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Ada", "role": "admin"}'
```

To read the body from a file instead of inlining it, use `@`:

```bash
curl -X POST https://api.example.com/users \
  -H "Content-Type: application/json" \
  -d @payload.json
```

## Authentication

For HTTP basic auth, `-u` takes `user:password`:

```bash
curl -u admin:secret https://api.example.com/private
```

Leave the password off (`-u admin`) and curl prompts for it, which keeps it out of your shell history. Token-based APIs usually want a header instead, via the `Authorization` header shown earlier.

## Inspecting responses

To see only the response headers, use `-I` (a HEAD request):

```bash
curl -I https://example.com
```

When something's behaving strangely, `-v` (verbose) shows the full exchange: the request line, every header sent and received, and the TLS handshake:

```bash
curl -v https://example.com
```

This is my go-to for debugging anything HTTP. If you want the body and the headers together without full verbosity, use `-i` instead.

## The -k flag, and why to avoid it

`-k` (or `--insecure`) tells curl to skip TLS certificate verification:

```bash
curl -k https://self-signed.local   # avoid in production
```

It's tempting when you hit a certificate error, but it disables the protection that stops man-in-the-middle attacks. Use it only against your own local services with self-signed certs. If you're getting an SSL error against a real site, fix the certificate trust instead. The [curl error 60 SSL certificate guide](/curl-60-ssl-certificate-problem-unable-to-get-local-issuer-certificate) explains how.

## Uploading files

To upload a file as a multipart form (like a browser form upload), use `-F`:

```bash
curl -F "file=@photo.jpg" https://api.example.com/upload
```

To upload raw file contents as the body (common for PUT to object storage), use `-T`:

```bash
curl -T backup.tar.gz https://storage.example.com/bucket/backup.tar.gz
```

## Wrapping up

Most real curl commands are just a few of these flags stacked together: a method, a couple of headers, some data, and `-L`. Once those are muscle memory, curl handles almost any "I need to hit this URL" situation you'll run into.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[504 Gateway Timeout in nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/504-gateway-timeout-nginx" />
            <id>https://rocketeersapp.com/504-gateway-timeout-nginx</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Your connection is not private]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/your-connection-is-not-private" />
            <id>https://rocketeersapp.com/your-connection-is-not-private</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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:

```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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[MySQL ERROR 1205: Lock wait timeout exceeded]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/mysql-1205-lock-wait-timeout-exceeded" />
            <id>https://rocketeersapp.com/mysql-1205-lock-wait-timeout-exceeded</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

```bash
SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded;
try restarting transaction
```

InnoDB uses row-level locks. When one transaction holds a lock on a row and another wants it, the second waits, up to `innodb_lock_wait_timeout` (default 50 seconds). If the lock isn't released in time, the waiting transaction is rolled back with error 1205.

## Why do I see this error

- A **long-running transaction** holding locks while it does slow work (or waits on something external).
- A transaction that was **never committed or rolled back**, left open by a crashed process or an interactive session.
- High contention: many requests updating the same rows at once.
- Deadlock-adjacent patterns where transactions acquire locks in different orders.

## Solution

### Find the blocking transaction

See which transactions are running and how long they've been open:

```sql
SELECT * FROM information_schema.innodb_trx\G
```

On MySQL 8 you can see exactly who blocks whom:

```sql
SELECT * FROM sys.innodb_lock_waits\G
```

If you find a transaction stuck open, kill it by its thread id:

```sql
KILL <trx_mysql_thread_id>;
```

### Keep transactions short

The durable fix is to hold locks for as little time as possible. Don't do slow work inside a transaction, no external API calls, no waiting on user input, no heavy computation between `BEGIN` and `COMMIT`. In Laravel:

```php
// Do the slow part first, outside the transaction
$data = $api->fetch();

DB::transaction(function () use ($data) {
    // only fast writes in here
    Order::create($data);
});
```

### Raise the timeout (last resort)

If a particular job legitimately needs longer, raise the wait timeout rather than leaving it default:

```ini
[mysqld]
innodb_lock_wait_timeout = 120
```

But treat this as a stopgap, a growing lock-wait time usually points at transactions that are too long or contention that needs a schema or indexing fix. Related: [MySQL 1040 too many connections](/mysql-1040-too-many-connections) when stuck transactions also exhaust connections.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Getting started with the AWS CLI]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/getting-started-aws-cli" />
            <id>https://rocketeersapp.com/getting-started-aws-cli</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## What the AWS CLI is for

Clicking through the AWS Console is fine for poking around, but the moment you want to script anything, list buckets quickly, or copy files to S3, the AWS CLI is the tool. It talks to every AWS service from your terminal, which means you can automate it and put it in deploy scripts. Here's how I get it running on a fresh machine.

## Installing the AWS CLI v2

Always install **v2**. Version 1 still floats around in package managers but is no longer the one you want.

On macOS, use the official installer:

```bash
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
```

On Linux (x86_64):

```bash
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
```

On an ARM box (like a Graviton instance or some Raspberry Pis), swap the URL for `awscli-exe-linux-aarch64.zip`.

Confirm it landed:

```bash
aws --version
```

You should see something like `aws-cli/2.x.x`.

## Configuring your credentials

Before anything works, the CLI needs an access key. Create one under **IAM, Users, Security credentials** in the Console, then run:

```bash
aws configure
```

It asks four questions:

- **AWS Access Key ID** — your key, like `AKIA...`
- **AWS Secret Access Key** — the secret half (shown only once when created)
- **Default region name** — e.g. `eu-west-1` or `us-east-1`
- **Default output format** — `json`, `table`, or `text`. I use `json`.

## Where credentials are stored

`aws configure` writes two plain-text files to `~/.aws/`:

- `~/.aws/credentials` holds your keys.
- `~/.aws/config` holds the region and output format.

Because these are plain text, treat `~/.aws/credentials` like a private SSH key: never commit it, and lock down its permissions if you're unsure. You can inspect the configured values without opening the files:

```bash
aws configure list
```

## Named profiles for multiple accounts

The moment you deal with more than one AWS account (work and personal, staging and production), named profiles save you. Set one up:

```bash
aws configure --profile staging
```

Then pass `--profile` on any command:

```bash
aws s3 ls --profile staging
```

You can also export it for a whole shell session so you don't repeat yourself:

```bash
export AWS_PROFILE=staging
```

## A few real commands

List your S3 buckets:

```bash
aws s3 ls
```

List the contents of one bucket, then copy a file up to it:

```bash
aws s3 ls s3://my-bucket/
aws s3 cp ./backup.tar.gz s3://my-bucket/backups/
```

The `aws s3 cp` command works just like a normal `cp`, including `--recursive` for whole directories. To sync a folder (only changed files), there's `aws s3 sync`, which behaves a lot like [rsync](/sync-files-rsync-command).

Check your EC2 instances:

```bash
aws ec2 describe-instances
```

That dumps a lot of JSON. Narrow it down with the built-in `--query` (JMESPath) to pull just what you need:

```bash
aws ec2 describe-instances \
  --query "Reservations[].Instances[].{ID:InstanceId,State:State.Name}" \
  --output table
```

## Where to go next

From here, almost everything is `aws <service> <command>`, and `aws <service> help` documents each one. Tab completion is worth setting up too. With credentials configured and v2 installed, you've got the foundation for scripting anything AWS exposes.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[500 Internal Server Error in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/500-internal-server-error-laravel" />
            <id>https://rocketeersapp.com/500-internal-server-error-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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
```]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[CSRF token mismatch in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/csrf-token-mismatch-laravel" />
            <id>https://rocketeersapp.com/csrf-token-mismatch-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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. 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
<form method="POST" action="/profile">
    @csrf
    <!-- fields -->
</form>
```

### AJAX requests

Expose the token in a meta tag and send it as a header on every request. With Axios:

```html
<meta name="csrf-token" content="{{ csrf_token() }}">
```

```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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to check your Ubuntu version from the command line]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/check-ubuntu-version-command-line" />
            <id>https://rocketeersapp.com/check-ubuntu-version-command-line</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Why you need to know your Ubuntu version

Before I install a package, follow a tutorial, or open a support ticket, I want to know exactly which Ubuntu release I'm on. The version decides which package versions are available, which docs apply, and whether a release is still getting security updates. Here are the commands I reach for.

## The quickest answer: lsb_release

My go-to is `lsb_release`, which prints the distributor info in a clean format:

```bash
lsb_release -a
```

The `-a` flag means "all". You'll get the distributor ID, a description like `Ubuntu 24.04.2 LTS`, the release number (`24.04`), and the codename (`noble`). If you only want the number, use `-r`, and `-c` gives just the codename:

```bash
lsb_release -r
lsb_release -c
```

## Reading /etc/os-release

`lsb_release` isn't installed on every minimal image, but `/etc/os-release` always is. It's a plain file you can just print:

```bash
cat /etc/os-release
```

This shows `VERSION_ID`, `VERSION_CODENAME`, and `PRETTY_NAME`. Because it's structured as shell variables, scripts can source it directly, which makes it handy in automation.

## The old-school /etc/issue

For a fast glance, `/etc/issue` holds the text shown before login:

```bash
cat /etc/issue
```

It prints something like `Ubuntu 24.04.2 LTS \n \l`. The `\n` and `\l` are placeholders the login prompt fills in, so just ignore them.

## A richer view with hostnamectl

`hostnamectl` gives the OS plus the hostname, architecture, and kernel in one go:

```bash
hostnamectl
```

I like this one on servers because the "Operating System" and "Kernel" lines tell me almost everything at a glance.

## Checking the kernel version

The Ubuntu release and the kernel are separate things. To see the running kernel, use `uname`:

```bash
uname -r
```

The `-r` flag prints just the kernel release, like `6.8.0-52-generic`. Want the full picture, including architecture and build date? Use `-a`:

```bash
uname -a
```

## Which one should you use?

For everyday use, `lsb_release -a` is the friendliest. On a stripped-down container or cloud image where it's missing, fall back to `cat /etc/os-release`. And when you're chasing a driver or hardware issue, `uname -r` is the kernel detail that actually matters.

Once you know your version, you'll likely want to [update Ubuntu from the command line](/update-ubuntu-command-line) to pull in the latest security patches.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Brotli vs Gzip: which compression should you use?]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/brotli-vs-gzip" />
            <id>https://rocketeersapp.com/brotli-vs-gzip</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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](/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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[MySQL ERROR 1040: Too many connections]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/mysql-1040-too-many-connections" />
            <id>https://rocketeersapp.com/mysql-1040-too-many-connections</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

```bash
ERROR 1040 (HY000): Too many connections
```

MySQL allows a fixed number of simultaneous client connections (`max_connections`, default 151). Once that ceiling is reached, every new connection is rejected until an existing one closes. For a web app this surfaces as failed requests across the whole site.

## Why do I see this error

- `max_connections` is genuinely too low for your traffic.
- A **connection leak**: the application opens connections but never closes them.
- Long-running queries holding slots open, see `SHOW PROCESSLIST`.
- A connection pool misconfigured to exceed the server's limit.
- A real traffic spike.

## Solution

### See what's using the connections

You'll usually need an account with the `SUPER` privilege (MySQL reserves one extra slot for it) to get in when full:

```sql
SHOW PROCESSLIST;
SHOW STATUS WHERE Variable_name = 'Threads_connected';
```

If you see dozens of idle `Sleep` connections, that's a leak or idle sessions not being reaped, not a reason to keep raising the limit.

### Raise the limit

Temporarily, without a restart:

```sql
SET GLOBAL max_connections = 300;
```

Permanently, in `my.cnf` under `[mysqld]`:

```ini
[mysqld]
max_connections = 300
```

Then restart MySQL. Don't set this arbitrarily high, each connection consumes memory, and a too-high value can exhaust RAM and take the server down harder than the original error.

### Reclaim idle connections faster

Lower the idle timeouts so abandoned connections free their slots sooner:

```ini
wait_timeout = 120
interactive_timeout = 120
```

### Fix the real cause in the app

For a Laravel app, make sure you're not opening connections in a loop and that long jobs run on a queue rather than holding a web request open. A leak will exhaust any limit you set. Related: [SQLSTATE[HY000] [2002] Connection refused](/sqlstate-hy000-2002-connection-refused) when MySQL can't be reached at all.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to checkout a Git tag]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/git-checkout-tag" />
            <id>https://rocketeersapp.com/git-checkout-tag</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Checkout a tag

A tag points at a specific commit, usually a release. Check it out by name:

```bash
git checkout v1.2.0
```

To be unambiguous (in case a branch and a tag share a name), use the full ref:

```bash
git checkout tags/v1.2.0
```

## You are now in detached HEAD

After checking out a tag, Git warns you that you are in a **detached HEAD** state. That means `HEAD` points directly at a commit instead of a branch. You can look around, build, and run the code, but any commits you make here are not on a branch and will be lost once you switch away.

## Make changes from a tag: create a branch

If you need to work from a tagged release, for example to patch an old version, create a branch from the tag:

```bash
git checkout -b hotfix-1.2.0 v1.2.0
```

The modern equivalent uses `git switch`:

```bash
git switch --detach v1.2.0      # just inspect
git switch -c hotfix-1.2.0 v1.2.0  # branch off the tag
```

## The tag is missing?

If `git checkout v1.2.0` fails with `pathspec ... did not match`, your clone probably does not have the tag yet. Fetch tags from the remote:

```bash
git fetch --tags
```

List the tags you have to confirm the exact name:

```bash
git tag
```]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[What port does SSH use (and how to change it)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/ssh-port" />
            <id>https://rocketeersapp.com/ssh-port</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## The default SSH port

SSH listens on **TCP port 22** by default. That is the port your client connects to unless you tell it otherwise:

```bash
ssh user@server.example.com        # implicitly port 22
ssh -p 22 user@server.example.com  # the same thing, spelled out
```

Port 22 is assigned to SSH by IANA, so it is what every client, firewall rule and hosting panel assumes.

## Connecting on a non-default port

If a server runs SSH on another port, pass it with `-p` (lowercase) for `ssh`, and `-P` (uppercase) for `scp`:

```bash
ssh -p 2222 user@server.example.com
scp -P 2222 file.txt user@server.example.com:/tmp/
```

To avoid typing it every time, set it in `~/.ssh/config`:

```text
Host myserver
    HostName server.example.com
    User deploy
    Port 2222
```

Then just `ssh myserver`.

## Changing the SSH port on the server

Edit the SSH daemon config and set the `Port` directive:

```bash
sudo nano /etc/ssh/sshd_config
```

```text
Port 2222
```

Before restarting, open the new port in your firewall, or you will lock yourself out:

```bash
sudo ufw allow 2222/tcp
```

On distributions with SELinux (RHEL, Rocky, AlmaLinux) you also have to register the port:

```bash
sudo semanage port -a -t ssh_port_t -p tcp 2222
```

Then restart the daemon:

```bash
sudo systemctl restart ssh    # or: sudo systemctl restart sshd
```

Keep your current session open and test the new port from a second terminal before logging out. If it works, you can remove the old `22/tcp` firewall rule.

## Does changing the port improve security?

Moving off port 22 cuts down the noise in your logs from automated bots scanning the default port, but it is **security through obscurity**, not real protection. A port scan finds the new port in seconds. The changes that actually matter:

- Use key-based authentication and set `PasswordAuthentication no`.
- Disable direct root login with `PermitRootLogin no`.
- Put the server behind a firewall and, ideally, only allow SSH from known IPs.

If your key auth is misconfigured, see [SSH Permission denied (publickey)](/ssh-permission-denied-publickey). For the related "address already in use" problem when a port is taken, see [Address already in use (port already bound)](/address-already-in-use-port).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[MySQL ERROR 1698: Access denied for user 'root'@'localhost']]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/mysql-1698-access-denied-auth-socket" />
            <id>https://rocketeersapp.com/mysql-1698-access-denied-auth-socket</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

```bash
ERROR 1698 (28000): Access denied for user 'root'@'localhost'
```

You're certain the password is correct, yet `mysql -u root -p` is rejected. This is the signature of the **`auth_socket`** authentication plugin, and it's specifically a 1698, not the [1045 access denied](/sqlstate-hy000-1045-access-denied-for-user) you'd get from genuinely wrong credentials.

## Why do I see this error

On modern Ubuntu/Debian, the MySQL `root` account is created to authenticate with the `auth_socket` (or `unix_socket`) plugin instead of a password. That plugin checks the **operating system user** running the client: if the OS user is `root`, it lets you in; otherwise it refuses, regardless of any password.

So `mysql -u root -p` as a normal user fails, while `sudo mysql` (running as the OS root) succeeds.

## Solution

### Log in the way the plugin expects

The quickest path in is simply:

```bash
sudo mysql
```

No password, because `auth_socket` trusts the OS root user.

### Switch root to password authentication

If you want classic password login (for a GUI client or a remote tool), change the plugin for the root account. Log in with `sudo mysql`, then:

```sql
ALTER USER 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY 'your-strong-password';
FLUSH PRIVILEGES;
```

On older MySQL/MariaDB use `mysql_native_password` instead of `caching_sha2_password`.

### Better: create a dedicated app user

Rather than handing your application the root account, create a least-privilege user scoped to one database:

```sql
CREATE USER 'app'@'localhost' IDENTIFIED BY 'your-strong-password';
GRANT ALL PRIVILEGES ON my_app.* TO 'app'@'localhost';
FLUSH PRIVILEGES;
```

Then point your app at `app` rather than `root`. See [environment variables in Laravel](/environment-variables-laravel) for wiring the credentials in.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to copy files over SSH with scp]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/copy-files-over-ssh-scp" />
            <id>https://rocketeersapp.com/copy-files-over-ssh-scp</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Copying files over SSH

Whenever I need to move a file to or from a server, `scp` is the tool I reach for first. It rides on top of SSH, so if you can already [connect to the server over SSH](/connect-to-server-ssh-command), you can copy files to it without any extra setup. The general syntax is `scp <source> <destination>`, where a remote location looks like `user@host:/path`.

## Local to remote

To upload a file from your machine to a server, put the remote location on the right:

```bash
scp ./backup.sql deploy@example.com:/var/www/backups/
```

This copies `backup.sql` into the `/var/www/backups/` directory on the server. If you want to rename it on the way, just include a filename in the destination path.

## Remote to local

Flip the arguments to download instead. Here the remote path is the source, and `.` is the current local directory:

```bash
scp deploy@example.com:/var/log/nginx/error.log .
```

## Copying directories with -r

By default `scp` only handles single files. To copy a whole directory and everything inside it, add `-r` (recursive):

```bash
scp -r ./public deploy@example.com:/var/www/app/
```

## Using a custom port with -P

If the server's SSH daemon listens on a non-standard port, pass it with `-P` (a capital P — lowercase `-p` preserves timestamps instead):

```bash
scp -P 2222 ./config.yml deploy@example.com:/etc/app/
```

## Using a specific key with -i

When the server expects a particular private key, point at it with `-i`. This is handy when you juggle several keys:

```bash
scp -i ~/.ssh/deploy_key ./release.tar.gz deploy@example.com:/tmp/
```

If you'd rather not type this every time, you can configure the key per host in `~/.ssh/config` and `scp` will pick it up automatically.

## Copying between two remote hosts

You don't even need the file to pass through your machine. Give two remote locations and `scp` copies directly between them:

```bash
scp deploy@host1.example.com:/data/dump.sql deploy@host2.example.com:/data/
```

Add `-3` if the two hosts can't reach each other directly and you want the transfer routed through your local machine instead.

## A few tips

You can combine flags, for example `scp -rP 2222` to recursively copy over a custom port. And if you're moving large directories around regularly, `rsync` is often a better fit since it only transfers what changed — but for a quick one-off copy, `scp` is hard to beat.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[nginx: upstream sent too big header]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/nginx-upstream-sent-too-big-header" />
            <id>https://rocketeersapp.com/nginx-upstream-sent-too-big-header</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to generate a self-signed certificate with OpenSSL]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/generate-self-signed-certificate-openssl" />
            <id>https://rocketeersapp.com/generate-self-signed-certificate-openssl</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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. Either way, make sure you serve the [full certificate chain](/what-is-an-ssl-certificate-chain).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to create and extract tar archives in Linux]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/create-extract-tar-archives-linux" />
            <id>https://rocketeersapp.com/create-extract-tar-archives-linux</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Working with tar archives

`tar` bundles many files and directories into a single archive file, optionally compressed. I use it constantly for backups, releases and moving directories between servers. The flags look cryptic at first, but they're just letters you combine.

The ones you'll use most:

- `c` — create a new archive
- `x` — extract an archive
- `t` — list the contents
- `v` — verbose, print each file as it's processed
- `f` — the next argument is the archive filename (always needed)
- `z` — pass the archive through gzip compression

## Create an archive

Bundle a directory into a `.tar` file with `c`, `v` and `f`:

```bash
tar -cvf backup.tar ./project
```

This creates `backup.tar` containing the `project` directory, printing each file as it goes.

## Create a compressed .tar.gz

Add `z` to gzip the archive as it's built. The convention is to name it `.tar.gz` (or `.tgz`):

```bash
tar -czvf backup.tar.gz ./project
```

Compressed archives are much smaller and the standard choice for backups or transferring over the network.

## Extract an archive

Swap `c` for `x` to extract. For an uncompressed archive:

```bash
tar -xvf backup.tar
```

For a gzip-compressed one, add `z` again:

```bash
tar -xzvf backup.tar.gz
```

Modern versions of `tar` actually detect the compression automatically, so `tar -xvf backup.tar.gz` usually works too — but being explicit with `z` never hurts.

## Extract to a specific directory

By default files land in the current directory. Use `-C` to extract somewhere else (the target directory must already exist):

```bash
tar -xzvf backup.tar.gz -C /var/www/releases
```

## List the contents

Before extracting an archive someone handed you, it's worth peeking inside with `t`. This lists every entry without unpacking anything:

```bash
tar -tvf backup.tar.gz
```

## Extract a single file

You don't have to unpack the whole thing. Name the exact path (as shown by the listing above) after the archive to pull out just that file:

```bash
tar -xzvf backup.tar.gz project/config/app.php
```

This recreates only `project/config/app.php` relative to your current directory.

## Putting it together

Once the letters click, `tar` reads naturally: `-czvf` is "create, gzip, verbose, file" and `-xzvf` is "extract, gzip, verbose, file". Those two cover the large majority of what you'll ever need.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[No space left on device on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/no-space-left-on-device-ubuntu" />
            <id>https://rocketeersapp.com/no-space-left-on-device-ubuntu</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Vite manifest not found in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/vite-manifest-not-found-laravel" />
            <id>https://rocketeersapp.com/vite-manifest-not-found-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to optimize MySQL performance]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/optimize-mysql-performance" />
            <id>https://rocketeersapp.com/optimize-mysql-performance</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Git fatal: refusing to merge unrelated histories]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/git-refusing-to-merge-unrelated-histories" />
            <id>https://rocketeersapp.com/git-refusing-to-merge-unrelated-histories</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Convert SSL certificate formats with OpenSSL]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/convert-ssl-certificate-formats" />
            <id>https://rocketeersapp.com/convert-ssl-certificate-formats</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[git reset --hard explained (soft vs mixed vs hard)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/git-reset-hard" />
            <id>https://rocketeersapp.com/git-reset-hard</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## What git reset does

`git reset` moves the current branch tip to a commit you choose. The flag controls how far the reset reaches:

- `--soft`: move the branch, **keep** everything staged.
- `--mixed` (the default): move the branch, unstage changes, keep them in your working directory.
- `--hard`: move the branch and **discard** all staged and working-directory changes.

## Undo the last commit, keep the work

To undo your most recent commit but keep the changes so you can re-commit them, use `--soft` or the default `--mixed`:

```bash
git reset --soft HEAD~1   # commit undone, changes still staged
git reset HEAD~1          # commit undone, changes unstaged but present
```

## Discard everything with --hard

`--hard` resets the branch and wipes uncommitted changes. Use it when you genuinely want to throw work away:

```bash
git reset --hard HEAD     # discard all uncommitted changes
git reset --hard HEAD~1   # delete the last commit and its changes
```

A common use is forcing your local branch to match the remote exactly:

```bash
git fetch origin
git reset --hard origin/main
```

This is destructive. Anything not committed is gone. If you only want to clean up *untracked* files (not reset tracked ones), use [git clean](/git-remove-untracked-files) instead.

## Recovering after a --hard reset

If you reset away a commit you actually needed, it is usually still findable for a while. `git reflog` records where `HEAD` has been:

```bash
git reflog
git reset --hard <commit-hash-from-reflog>
```

Note that `reflog` only recovers committed work. Changes that were never committed and got discarded by `--hard` cannot be recovered. When in doubt, [stash](/git-stash-pop) your changes instead of resetting.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to generate an SSH key]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/generate-ssh-key" />
            <id>https://rocketeersapp.com/generate-ssh-key</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to clear the Redis cache]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/clear-redis-cache" />
            <id>https://rocketeersapp.com/clear-redis-cache</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[docker exec: run a command in a running container]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/docker-exec" />
            <id>https://rocketeersapp.com/docker-exec</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Open a shell inside a container

The most common use of `docker exec` is getting a shell in a running container. The `-it` flags give you an interactive terminal:

```bash
docker exec -it my-container bash
```

If the image is minimal and has no `bash`, fall back to `sh`:

```bash
docker exec -it my-container sh
```

The `-i` keeps STDIN open and `-t` allocates a pseudo-TTY; together they make the shell behave like a normal terminal.

## Run a one-off command

You do not need a shell for a single command. Anything after the container name is run inside it:

```bash
docker exec my-container ls -la /app
docker exec my-container cat /etc/hostname
```

For a Laravel container you might run Artisan, or open a database client:

```bash
docker exec -it my-app php artisan migrate
docker exec -it my-db mysql -u root -p
```

## Useful flags

```bash
docker exec -u www-data my-container whoami   # run as a specific user
docker exec -w /app my-container pwd           # set the working directory
docker exec -e DEBUG=1 my-container env        # pass an environment variable
```

## exec vs run vs attach

These look similar but do different things:

- `docker exec` runs a command in a container that is **already running**.
- `docker run` creates a **new** container from an image.
- `docker attach` connects to the container's main process (its PID 1), so `Ctrl+C` there can stop the container, which is usually not what you want.

To find the container name or ID to pass to `exec`, list running containers with `docker ps`. If you started everything with Compose, see [docker compose up](/docker-compose-up).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[SSH Permission denied (publickey)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/ssh-permission-denied-publickey" />
            <id>https://rocketeersapp.com/ssh-permission-denied-publickey</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Laravel failed to open stream: Permission denied]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/laravel-failed-to-open-stream-permission-denied" />
            <id>https://rocketeersapp.com/laravel-failed-to-open-stream-permission-denied</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to check SSL certificate expiration]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/check-ssl-certificate-expiration" />
            <id>https://rocketeersapp.com/check-ssl-certificate-expiration</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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.

## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to run a command on a remote server over SSH]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/run-command-over-ssh" />
            <id>https://rocketeersapp.com/run-command-over-ssh</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Running a command without logging in

Most of the time when I SSH into a server I just want to run a single command and get out. Instead of starting an interactive session, you can pass the command straight to `ssh` and it runs there, prints the output locally and disconnects. This builds on a working SSH setup, so if you can [connect to the server over SSH](/connect-to-server-ssh-command), this works out of the box.

The pattern is `ssh user@host 'command'`:

```bash
ssh deploy@example.com 'uptime'
```

## Quoting matters

Always wrap the command in single quotes. Without them, your local shell may expand variables and globs before they ever reach the server. Compare these two:

```bash
ssh deploy@example.com 'echo $HOME'   # prints the server's $HOME
ssh deploy@example.com "echo $HOME"   # prints your local $HOME
```

Use single quotes when you want the remote shell to interpret the command.

## Running multiple commands

Chain commands with `&&` (stop on failure) or `;` (always continue), all inside the same quotes:

```bash
ssh deploy@example.com 'cd /var/www/app && git pull && composer install'
```

## Using sudo over SSH

Running `sudo` non-interactively needs a terminal for the password prompt. Add `-t` to force one to be allocated:

```bash
ssh -t deploy@example.com 'sudo systemctl restart nginx'
```

The `-t` flag gives `sudo` a proper TTY so it can ask for your password.

## Capturing output locally

Because the output comes back over the connection, you can redirect or pipe it on your own machine like any other command:

```bash
ssh deploy@example.com 'cat /var/log/syslog' > server-syslog.txt
ssh deploy@example.com 'df -h' | grep '/dev/sda1'
```

## Running a local script on the server

A neat trick: you can feed a script from your machine into a remote shell without copying it over first. Pipe it into `bash -s`:

```bash
ssh deploy@example.com 'bash -s' < ./provision.sh
```

The remote `bash -s` reads the script from standard input, which `ssh` pipes from your local file. You can even pass arguments to the script after `bash -s`:

```bash
ssh deploy@example.com 'bash -s' -- arg1 arg2 < ./provision.sh
```

This is great for ad-hoc provisioning or maintenance scripts you keep locally but want to run on several servers without uploading them each time.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to change the SSH port on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/change-ssh-port-ubuntu" />
            <id>https://rocketeersapp.com/change-ssh-port-ubuntu</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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.

## 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
```]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Fix: Cannot connect to the Docker daemon at unix:///var/run/docker.sock]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/cannot-connect-to-docker-daemon" />
            <id>https://rocketeersapp.com/cannot-connect-to-docker-daemon</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

```bash
Cannot connect to the Docker daemon at unix:///var/run/docker.sock.
Is the docker daemon running?
```

The `docker` command you type is just a client. It sends instructions to a background service, the Docker daemon (`dockerd`), over a Unix socket at `/var/run/docker.sock`. This error appears when the client cannot reach that daemon.

## Why do I see this error

- The Docker daemon is not running.
- Your user is not allowed to access the socket (a permissions issue).
- Your client is pointed at the wrong Docker context or a stale `DOCKER_HOST`.

## Solution: start the daemon

On **macOS or Windows**, the daemon runs inside Docker Desktop. Make sure Docker Desktop is open and has finished starting (the whale icon stops animating).

On **Linux**, start and enable the service so it also comes back after a reboot:

```bash
sudo systemctl start docker
sudo systemctl enable docker
```

Check that it is actually running:

```bash
sudo systemctl status docker
```

## Solution: fix the permission denied case

If the daemon is running but you still see the error (often phrased as `permission denied while trying to connect`), your user is not in the `docker` group. Add it:

```bash
sudo usermod -aG docker $USER
```

Group membership only applies to new sessions, so log out and back in, or start a fresh shell with:

```bash
newgrp docker
```

Then verify:

```bash
docker run hello-world
```

## Still failing? Check the context

If Docker is running and you have permission, your client may be pointed somewhere else. List your contexts and confirm the active one:

```bash
docker context ls
```

Also check for a leftover environment variable overriding the socket:

```bash
echo $DOCKER_HOST
```

If `DOCKER_HOST` is set to something unexpected, unset it and try again. Once the daemon is reachable, you can [clean up unused Docker data with prune](/docker-prune) or [run a command in a container with docker exec](/docker-exec).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to connect to a server with the ssh command]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/connect-to-server-ssh-command" />
            <id>https://rocketeersapp.com/connect-to-server-ssh-command</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Connecting with ssh

SSH is how I reach almost every server I work with. The basic form is the command, the user, and the host:

```bash
ssh jane@203.0.113.10
```

You can use a hostname instead of an IP, and SSH will resolve it:

```bash
ssh jane@app.example.com
```

If you don't pass a key explicitly, SSH tries the default keys in `~/.ssh`. If you don't have a key yet, generate one first: [create SSH keys with ssh-keygen](/generate-ssh-keys-ssh-keygen).

## Custom port

SSH listens on port 22 by default. Many servers move it elsewhere for security. Use `-p` (lowercase) to specify the port:

```bash
ssh -p 2222 jane@app.example.com
```

## Using a specific key file

Cloud providers often hand you a `.pem` file. Point SSH at it with `-i` (identity file):

```bash
ssh -i ~/.ssh/aws-key.pem ubuntu@203.0.113.10
```

If the key has loose permissions, SSH refuses to use it. Fix it with `chmod 400 ~/.ssh/aws-key.pem` so only you can read it.

## The first-connection prompt

The first time you connect to a host, SSH shows the server's fingerprint and asks:

```bash
The authenticity of host 'app.example.com' can't be established.
ED25519 key fingerprint is SHA256:abc123...
Are you sure you want to continue connecting (yes/no/[fingerprint])?
```

Type `yes` and SSH stores the fingerprint in `~/.ssh/known_hosts`. On later connections it checks silently. This is expected; it only warns you again if the fingerprint changes.

## Host aliases with ~/.ssh/config

Typing long commands gets old fast. I keep a `~/.ssh/config` so I can connect with a short alias:

```bash
Host prod
    HostName app.example.com
    User jane
    Port 2222
    IdentityFile ~/.ssh/aws-key.pem
```

Now the whole thing collapses to:

```bash
ssh prod
```

Every flag above can live in the config, which keeps it out of your muscle memory and your shell history.

## Debugging with -v

When a connection misbehaves, `-v` (verbose) shows exactly what SSH is doing: which keys it offers, what the server accepts, and where it stops. Add more `v`s for more detail:

```bash
ssh -v prod
ssh -vvv prod
```

If you hit `Permission denied (publickey)`, I've written up the causes and fixes here: [SSH permission denied (publickey)](/ssh-permission-denied-publickey).

## Where to go next

Once you can connect, you can do more than open a shell. Run a single command without a full session with [run a command over SSH](/run-command-over-ssh), and move files with [scp over SSH](/copy-files-over-ssh-scp).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to enable Gzip and Brotli compression in Nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/enable-gzip-compression-nginx" />
            <id>https://rocketeersapp.com/enable-gzip-compression-nginx</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[Compression is one of the cheapest performance wins you can ship. Text assets like HTML, CSS, JavaScript, and JSON compress extremely well, often by 70-80%, so enabling it cuts transfer size and improves load time for every visitor. The good news: to enable Gzip compression in Nginx you only need a handful of directives. This guide covers Nginx Gzip, then adds Brotli for an even better ratio.

## Enable Gzip in Nginx

Gzip is built into Nginx, so enabling it is just configuration. Add this to the `http {}` block in `/etc/nginx/nginx.conf` (or a file included from it):

```nginx
http {
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_vary on;
    gzip_proxied any;
    gzip_types
        text/plain
        text/css
        text/xml
        application/json
        application/javascript
        application/xml+rss
        application/atom+xml
        image/svg+xml;
}
```

What each directive does:

- **`gzip on`** — turns compression on. By default Nginx only compresses `text/html`, which is why `gzip_types` matters.
- **`gzip_types`** — the MIME types to compress. List your text-based assets here. There's no point compressing already-compressed formats like JPEG, PNG, or WOFF2, so leave those out.
- **`gzip_comp_level`** — compression effort from 1 to 9. Higher means smaller files but more CPU per request. Levels 4-6 are the sweet spot; past that you burn CPU for marginal size gains. Start at 5.
- **`gzip_min_length 256`** — skip tiny responses. Compressing a 50-byte payload can make it larger and wastes CPU, so only compress responses of at least 256 bytes.
- **`gzip_vary on`** — adds `Vary: Accept-Encoding` so caches and CDNs store compressed and uncompressed variants separately.
- **`gzip_proxied any`** — compress responses even when the request comes through a proxy. Useful behind a load balancer or CDN.

## Reload Nginx

Always test the config before reloading so a typo doesn't take the server down:

```bash
nginx -t && systemctl reload nginx
```

`nginx -t` validates the syntax; the reload only runs if the test passes. A reload is graceful, so existing connections aren't dropped.

## Verify it works

Don't assume it's working, check the response headers. Request a text asset and ask for Gzip:

```bash
curl -H 'Accept-Encoding: gzip' -I https://example.com
```

Look for this in the output:

```
content-encoding: gzip
vary: Accept-Encoding
```

If `Content-Encoding: gzip` is present, compression is active. If it's missing, the most common causes are the response type not being in `gzip_types`, the body being under `gzip_min_length`, or a proxy in front stripping the `Accept-Encoding` header. Note that browsers send `Accept-Encoding` automatically; the `-H` flag just forces it for the test.

## Add Brotli for a better ratio

Brotli typically compresses 15-25% smaller than Gzip on text, and every modern browser supports it. The catch: **Brotli is not bundled with Nginx by default.** You need the `ngx_brotli` module, either compiled in or loaded as a dynamic module.

On Debian/Ubuntu the dynamic modules are often available as a package:

```bash
apt install libnginx-mod-brotli
```

Then load the modules at the very top of `nginx.conf` (before the `http {}` block):

```nginx
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;
```

Now configure Brotli inside `http {}`, alongside Gzip. Keeping both means clients that don't support Brotli still fall back to Gzip:

```nginx
http {
    brotli on;
    brotli_comp_level 5;
    brotli_types
        text/plain
        text/css
        application/json
        application/javascript
        image/svg+xml;

    # static precompression
    brotli_static on;
    gzip_static on;
}
```

`brotli_static on` and `gzip_static on` enable **static precompression**: if a `style.css.br` or `style.css.gz` file sits next to `style.css`, Nginx serves the precompressed version directly instead of compressing on the fly. This is ideal for build artifacts, you compress once at deploy time at maximum level and pay zero CPU per request. Generate the files in your build step and ship them alongside the originals.

Test and reload exactly as before:

```bash
nginx -t && systemctl reload nginx
```

To verify Brotli, ask for it explicitly and check for `content-encoding: br`:

```bash
curl -H 'Accept-Encoding: br' -I https://example.com
```

## Which should you choose

You don't have to choose, run both. Configure Brotli and Gzip together and let the browser negotiate: capable clients get Brotli, everything else falls back to Gzip. For a deeper comparison of ratios and CPU cost, see [Brotli vs Gzip](/brotli-vs-gzip).

## Conclusion

Enabling Gzip in Nginx is a few directives and a reload, and it pays off on every text response you serve. Add `gzip on` with a sensible `gzip_types` list and `gzip_comp_level 5`, confirm it with `curl`, then layer Brotli on top via `ngx_brotli` for a better ratio with Gzip as the fallback. For build assets, precompress with `brotli_static`/`gzip_static` to get maximum compression at no runtime cost.

Compression is one piece of the puzzle. To go further, see how it fits into [optimizing website performance](/optimize-website-performance) and how serving precompressed assets helps your [time to first byte](/ttfb).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[PHP Warning: Undefined array key]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/undefined-array-key-php" />
            <id>https://rocketeersapp.com/undefined-array-key-php</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to make a script executable with chmod +x]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/make-script-executable-chmod-x" />
            <id>https://rocketeersapp.com/make-script-executable-chmod-x</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Why a new script says "Permission denied"

You write a script, try to run it, and Linux throws this at you:

```bash
$ ./deploy.sh
bash: ./deploy.sh: Permission denied
```

Nothing is wrong with your code. By default a freshly created file is readable and writable, but not *executable*. Linux refuses to run a file unless its execute permission bit is set, and that's exactly what's missing here.

## Add the execute bit with chmod +x

The fix is one command. `chmod` changes a file's mode, and `+x` adds the execute permission:

```bash
chmod +x deploy.sh
```

That `+x` grants execute to the user, group, and others at once (limited by your umask). Now Linux is allowed to run the file.

## Run the script

With the bit set, run it by giving its path:

```bash
./deploy.sh
```

The `./` matters. It tells the shell "the script is right here in the current directory". Without it, the shell only searches the directories in your `$PATH`, and your script almost certainly isn't in one of those.

## Don't forget the shebang line

The execute bit lets Linux *run* the file, but the file also needs to say *which interpreter* runs it. That's the job of the shebang, the very first line:

```bash
#!/bin/bash
echo "Deploying..."
```

`#!/bin/bash` tells the kernel to feed this file to Bash. For a more portable script you might use `#!/usr/bin/env bash`, which finds Bash via the `PATH`. Without a shebang, the script may run under whatever shell you happen to be in, which can bite you with subtle syntax differences.

## The chmod 755 equivalent

You'll often see numeric permissions instead of the `+x` shorthand:

```bash
chmod 755 deploy.sh
```

This sets read, write, and execute for the owner (`7`), and read plus execute for the group and everyone else (`5` and `5`). For most scripts `755` is the sensible default. If a script holds secrets and only you should run it, use `700` instead so the group and others get nothing.

## When to use which

For a quick "just let me run this", `chmod +x` is all you need. When you want exact, predictable permissions, reach for the numeric form like `755`. If you want to really understand what those numbers mean and how owner, group, and other fit together, read my deeper guide on [changing file permissions with chmod](/change-file-permissions-chmod-linux).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[SQLSTATE[23000] 1062 Duplicate entry]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/sqlstate-23000-1062-duplicate-entry" />
            <id>https://rocketeersapp.com/sqlstate-23000-1062-duplicate-entry</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

In Laravel the message looks like:

```bash
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'jane@example.com' for key 'users.users_email_unique'
```

It tells you three useful things: the value that clashed (`jane@example.com`), the column it clashed on (via the key name), and that it was a uniqueness rule. MySQL refused the write to keep the column unique.

## Why do I see this error

- You inserted a record whose unique column (an email, a slug, a username) already exists.
- A seeder or migration was run twice.
- An auto-increment primary key got out of sync, often after importing data.
- A race condition: two requests inserted the "same" new record at almost the same time.

## Solution

### Validate before inserting

In Laravel, catch duplicates with a validation rule so the user gets a clean message instead of a 500:

```php
$request->validate([
    'email' => 'required|email|unique:users,email',
]);
```

### Use upsert / updateOrCreate for idempotent writes

If re-running the operation should update rather than fail, don't blind-insert:

```php
User::updateOrCreate(
    ['email' => $email],   // match on this
    ['name' => $name],     // update these
);
```

For bulk operations, `upsert()` does the same in a single query.

### Reset a broken auto-increment

If the clash is on the primary key after an import, realign the counter to just past the highest existing id:

```sql
ALTER TABLE users AUTO_INCREMENT = 1;
```

MySQL bumps the value to the next free id automatically.

### Re-running seeders

If a seeder keeps colliding, either make it idempotent with `updateOrCreate`, or refresh the schema first on a development database:

```bash
php artisan migrate:fresh --seed
```

Remember `migrate:fresh` drops all tables, so never run it where there's data you need.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[PHP Fatal error: Allowed memory size exhausted]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/php-allowed-memory-size-exhausted" />
            <id>https://rocketeersapp.com/php-allowed-memory-size-exhausted</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[502 Bad Gateway in nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/502-bad-gateway-nginx" />
            <id>https://rocketeersapp.com/502-bad-gateway-nginx</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[PHP: Call to a member function on null]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/call-to-a-member-function-on-null" />
            <id>https://rocketeersapp.com/call-to-a-member-function-on-null</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Target class does not exist in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/target-class-does-not-exist-laravel" />
            <id>https://rocketeersapp.com/target-class-does-not-exist-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to change file permissions with chmod]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/change-file-permissions-chmod-linux" />
            <id>https://rocketeersapp.com/change-file-permissions-chmod-linux</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## How Linux permissions work

Every file and directory on Linux and macOS has permissions for three groups: the **user** (owner), the **group**, and **others** (everyone else). Each group can have three permissions: **read** (`r`), **write** (`w`), and **execute** (`x`).

Run `ls -l` and you'll see them on the left:

```bash
ls -l
-rw-r--r--  1 jane staff  1240 Jun 23 10:00 notes.txt
```

The first character is the file type (`-` for a file, `d` for a directory). The next nine are three blocks of `rwx`: owner, group, others. Above, the owner can read and write, while group and others can only read.

`chmod` ("change mode") is how you adjust those bits.

## Symbolic mode

Symbolic mode is the readable way to make targeted changes. You name the target (`u` user, `g` group, `o` others, `a` all), then `+`, `-`, or `=`, then the permission.

```bash
chmod u+x deploy.sh    # give the owner execute
chmod g-w report.txt   # remove write from the group
chmod o=r config.ini   # set others to read-only exactly
chmod a+r public.txt   # give everyone read
```

The `+` adds, `-` removes, and `=` sets exactly (clearing anything not listed). This is my go-to when I just need to flip one bit. Making a script runnable is so common I gave it its own write-up: [make a script executable with chmod +x](/make-script-executable-chmod-x).

## Numeric (octal) mode

Numeric mode sets all three groups at once. Each digit is the sum of read (`4`), write (`2`), and execute (`1`):

- `7` = `rwx` (4+2+1)
- `6` = `rw-` (4+2)
- `5` = `r-x` (4+1)
- `4` = `r--`

The three digits are owner, group, others, in that order:

```bash
chmod 755 deploy.sh    # rwxr-xr-x — owner full, others read+execute
chmod 644 notes.txt    # rw-r--r-- — owner read+write, others read
chmod 600 secret.key   # rw------- — owner only
```

`755` is the standard for scripts and directories; `644` is standard for regular files.

## Recursive changes

To apply a mode to a directory and everything inside it, add `-R`:

```bash
chmod -R 755 ./public
```

Be careful here. Files and directories usually want different permissions (directories need execute to be enterable), so a blanket recursive chmod can break things. When that matters I use `find` to target each type separately:

```bash
find ./public -type d -exec chmod 755 {} \;
find ./public -type f -exec chmod 644 {} \;
```

## A word on 777

You'll see `chmod 777` suggested as a quick fix all over the internet. It grants read, write, and execute to **everyone**, which is almost never what you want. On a shared server it's a real security risk. If something isn't working, the fix is usually correct ownership (`chown`) or a sensible `644`/`755`, not throwing the doors wide open.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to find your IP address from the Linux command line]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/find-ip-address-linux-command-line" />
            <id>https://rocketeersapp.com/find-ip-address-linux-command-line</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Two different IP addresses

There's a catch that trips people up: your machine has two kinds of IP address. The local (private) one identifies you on your own network, like `192.168.1.42`. The public one is what the rest of the internet sees, assigned by your router or hosting provider. You find them in completely different ways.

## Finding your local (private) IP

The modern tool is `ip`. To list every interface and its addresses:

```bash
ip addr
```

That's a lot of output. `ip a` is the same thing, just shorter. Look for your active interface (often `eth0`, `ens3`, or `wlan0`) and the `inet` line under it, which holds your IPv4 address. To skip the noise and jump straight to the answer:

```bash
hostname -I
```

The capital `-I` prints all assigned IP addresses on one line, space-separated. It's the fastest way to grab your local IP, though on a machine with several interfaces you'll get several addresses back.

## The older ifconfig

You'll still see `ifconfig` in older tutorials:

```bash
ifconfig
```

It's deprecated and not installed by default on recent Ubuntu, but it still works if `net-tools` is present (`sudo apt install net-tools`). I'd stick with `ip` on anything modern.

## Finding your public IP

None of the above shows your public address when you're behind a router. For that, you ask an external service what it sees. `curl` makes this trivial:

```bash
curl ifconfig.me
```

That hits a service which simply echoes back the IP your request came from. There are several of these; another reliable one is ipify:

```bash
curl -s https://api.ipify.org
```

The `-s` flag runs curl silently, so you don't get a progress bar mixed into the output. Both print your public IPv4 address and nothing else.

## Quick reference

- Local IP, full detail: `ip addr`
- Local IP, one line: `hostname -I`
- Public IP: `curl ifconfig.me`

When I'm SSH'd into a fresh server and need to know how the world reaches it, `curl ifconfig.me` is the one I type without thinking.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to rename a Git branch (local and remote)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/git-rename-branch" />
            <id>https://rocketeersapp.com/git-rename-branch</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Rename the branch you are on

The `-m` (move) flag renames a branch. To rename the branch you currently have checked out, you only need the new name:

```bash
git branch -m new-name
```

## Rename a different branch

To rename a branch without checking it out first, give both the old and new names:

```bash
git branch -m old-name new-name
```

## Push the rename to the remote

A remote branch cannot be renamed in place. The workflow is: push the new name, then delete the old one.

First push the renamed branch and set it as the upstream:

```bash
git push origin -u new-name
```

Then delete the old branch from the remote:

```bash
git push origin --delete old-name
```

If other people work on this branch, tell them: their local clones still track the old name and will need `git fetch --prune` to clean it up.

## Renaming the default branch

The same steps apply to a default branch (for example renaming `master` to `main`), but you also have to update the default on your hosting platform (GitHub, GitLab) in the repository settings, and update any branch protection rules or CI configuration that referenced the old name.

To delete a branch entirely rather than rename it, see [How to delete a local (and remote) Git branch](/git-delete-local-branch).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[MySQL ERROR 2006: server has gone away]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/mysql-2006-server-has-gone-away" />
            <id>https://rocketeersapp.com/mysql-2006-server-has-gone-away</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

```bash
ERROR 2006 (HY000): MySQL server has gone away
```

It means the client sent a query but the connection to the server was already closed or dropped. The message is generic, MySQL is telling you the link is gone, not why.

## Why do I see this error

Three causes account for almost every case:

- **Idle timeout**: the connection sat idle longer than `wait_timeout` and MySQL closed it. Common with long-running queue workers and scheduled jobs.
- **Oversized query**: a single statement exceeded `max_allowed_packet`, so the server dropped the connection. See [MySQL max_allowed_packet: packet too large](/mysql-max-allowed-packet-packet-too-large).
- **The server restarted or was OOM-killed** while the client held a connection.

## Solution

### Idle timeout

If the error happens after periods of inactivity, raise the idle timeout in `my.cnf`:

```ini
[mysqld]
wait_timeout = 600
interactive_timeout = 600
```

Then restart MySQL. For a long-running PHP worker, the more robust fix is to **reconnect** rather than hold one connection open for hours. In Laravel, a queue worker that reconnects on each job avoids the problem; you can also have the worker restart periodically:

```bash
php artisan queue:work --max-time=3600
```

### Oversized query

If it happens on a specific large insert or import, the packet limit is the cause, not the timeout. Raise it on both server and client:

```ini
[mysqld]
max_allowed_packet = 256M
```

See the dedicated [max_allowed_packet article](/mysql-max-allowed-packet-packet-too-large) for the full walkthrough.

### Server restarted

If neither applies, check whether MySQL crashed or was killed. Look at the MySQL error log and the system journal:

```bash
sudo tail -n 50 /var/log/mysql/error.log
sudo journalctl -u mysql --since "1 hour ago"
```

An out-of-memory kill points at server sizing, see [adding swap space on Ubuntu](/add-swap-space-on-ubuntu).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to improve Laravel performance]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/laravel-performance" />
            <id>https://rocketeersapp.com/laravel-performance</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[Laravel is fast out of the box, but the default development setup leaves a lot on the table. Most Laravel performance problems aren't the framework, they're a missing cache or an N+1 query. This guide walks through the changes that actually move the needle, in rough order of impact: production caching, the autoloader, query patterns, queues, drivers, and OPcache.

## Run the production caching commands

In development, Laravel re-parses your config, routes, and views on every request so changes show up instantly. In production that's wasted work. The framework can compile each of these into a single cached file.

```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
```

Or run them all at once:

```bash
php artisan optimize
```

This is the single biggest Laravel performance win and it costs nothing. The catch: these caches are snapshots. Any change to a config, route, or `.env` value won't take effect until you rebuild them, so the caches must be cleared and rebuilt on every deploy. See [clearing Laravel caches](/clear-cache-laravel) for the full set of clear commands.

One gotcha with `config:cache`: once config is cached, `env()` returns `null` outside of config files. Always read environment values through `config()` in your app code.

## Optimize the Composer autoloader

By default Composer's autoloader maps namespaces to directories and hits the filesystem to find each class. In production you want a flat, pre-computed class map instead, and you want to skip dev dependencies entirely.

```bash
composer install --optimize-autoloader --no-dev
```

`--optimize-autoloader` builds a single classmap so every class resolves in one lookup. `--no-dev` drops packages like PHPUnit and Faker that have no business running in production. Run this as part of your deploy, not on your laptop.

## Fix N+1 queries with eager loading

After caching, the most common Laravel performance killer is the N+1 query. It happens when you loop over a collection and lazily access a relationship, firing one query per row.

```php
// 1 query for posts, then 1 query per post for its author = N+1
$posts = Post::all();

foreach ($posts as $post) {
    echo $post->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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[MySQL ERROR 1064: SQL syntax error]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/mysql-1064-sql-syntax-error" />
            <id>https://rocketeersapp.com/mysql-1064-sql-syntax-error</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

```bash
ERROR 1064 (42000): You have an error in your SQL syntax;
check the manual that corresponds to your MySQL server version
for the right syntax to use near '...' at line 1
```

The crucial part is the text after **`near`**. MySQL prints the query starting from the point where it got confused, so the problem is almost always at or just *before* that snippet, not somewhere else.

## Why do I see this error

- A **reserved word** used as a table or column name without backticks (`order`, `group`, `rank`, `key`, `desc`).
- A typo in a keyword (`SELCT`, `FORM`, `WHERE` placed wrong).
- A missing or mismatched quote or parenthesis.
- A stray trailing comma before `FROM` or a closing bracket.
- Mixing in another SQL dialect's syntax that MySQL doesn't accept.

## Solution

### Read the "near" snippet

If you see `near 'order (id int...'`, the word right after `near` (`order`) is the offender. `ORDER` is reserved, so quote it with backticks:

```sql
CREATE TABLE `order` (
  `id` INT PRIMARY KEY,
  `rank` INT
);
```

Backticks let you use reserved words as identifiers, though renaming the column to something non-reserved is cleaner long term.

### Check quoting

Use single quotes for string values and backticks for identifiers, never the other way around:

```sql
-- wrong
SELECT * FROM users WHERE name = "O'Brien";
-- right
SELECT * FROM users WHERE name = 'O\'Brien';
```

### When it comes from application code

If the query is built by your app, the syntax error usually means a variable was interpolated into the SQL unescaped, which is also a SQL injection risk. Use parameter binding instead of string concatenation. In Laravel, prefer the query builder or bindings:

```php
DB::select('select * from users where email = ?', [$email]);
```

This both fixes the syntax problem and closes the injection hole.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to configure rate limiting in nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/nginx-rate-limiting" />
            <id>https://rocketeersapp.com/nginx-rate-limiting</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to sync files and directories with rsync]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/sync-files-rsync-command" />
            <id>https://rocketeersapp.com/sync-files-rsync-command</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Why rsync instead of scp

When I need to move a directory between machines, my first instinct used to be `scp`. It works, but it copies everything every single time. The moment I started syncing a large project folder repeatedly, that got painfully slow.

`rsync` is smarter. It compares source and destination and only transfers the parts that actually changed (the deltas). The first sync is a full copy, but every sync after that is fast because it skips files that are already identical. That makes it perfect for backups and deploys. If you only need a one-off file copy, [scp](/copy-files-over-ssh-scp) is still fine, but for anything you run more than once, reach for rsync.

## The basic local sync

The command I use most is short:

```bash
rsync -av source/ destination/
```

The flags:

- `-a` is archive mode. It's a bundle that preserves permissions, timestamps, symlinks, and ownership, and recurses into subdirectories. This is what you want 95% of the time.
- `-v` is verbose, so you see which files are being transferred.

Add `-h` if you want human-readable sizes in the output (`-avh`).

## The trailing-slash gotcha

This one bites everyone once. A trailing slash on the **source** changes the meaning:

```bash
# Copies the CONTENTS of source/ into destination/
rsync -av source/ destination/

# Copies the source FOLDER itself into destination/ (destination/source/)
rsync -av source destination/
```

With the slash, you get `destination/file.txt`. Without it, you get `destination/source/file.txt`. When something ends up one level too deep, this is almost always why.

## Syncing over SSH

rsync runs over SSH by default for remote paths, so syncing to a server is straightforward:

```bash
rsync -avz -e ssh local/ user@host:/var/www/app/
```

- `-z` compresses data during transfer, which helps a lot over slower connections.
- `-e ssh` sets SSH as the transport. You can pass options here too, like a custom port: `-e "ssh -p 2222"`.

Pulling from a server to your machine is just the two paths swapped:

```bash
rsync -avz user@host:/var/www/app/ ./backup/
```

If you haven't set up key-based access yet, see [connecting to a server with SSH](/connect-to-server-ssh-command) first so you're not typing a password on every sync.

## Always dry-run first

Before any sync that deletes or overwrites, I run it with `--dry-run` (or `-n`). It shows exactly what would happen without touching a single file:

```bash
rsync -av --dry-run source/ destination/
```

Pair it with `--progress` once you're happy and want to watch a real transfer:

```bash
rsync -av --progress source/ destination/
```

## Mirroring with --delete

By default rsync never removes files from the destination. If you want a true mirror, where files deleted from the source also disappear from the destination, add `--delete`:

```bash
rsync -av --delete source/ destination/
```

This is powerful and dangerous. Run it with `--dry-run` first, every time. A misplaced trailing slash plus `--delete` is how people wipe a backup.

## Excluding files

You rarely want to sync everything. Skip `node_modules`, `.git`, and friends with `--exclude`:

```bash
rsync -avz --exclude '.git' --exclude 'node_modules' --exclude '*.log' \
  ./app/ user@host:/var/www/app/
```

For longer lists, put patterns in a file and use `--exclude-from`:

```bash
rsync -avz --exclude-from='.rsyncignore' ./app/ user@host:/var/www/app/
```

## A practical deploy command

Putting it together, here's the kind of one-liner I keep around for deploying a build:

```bash
rsync -avz --delete --progress \
  --exclude '.git' --exclude '.env' \
  ./public/ user@host:/var/www/app/public/
```

Run it once with `--dry-run` appended, confirm the file list, then run it for real. Once you've done that a couple of times, rsync becomes the tool you never think twice about.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[PHP Fatal error: Class "X" not found]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/php-class-not-found" />
            <id>https://rocketeersapp.com/php-class-not-found</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to install the Xcode Command Line Tools on macOS]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/install-xcode-command-line-tools" />
            <id>https://rocketeersapp.com/install-xcode-command-line-tools</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Why you need the Command Line Tools

On a fresh Mac, most developer tooling won't work until you install the Xcode Command Line Tools. This is a lightweight package (you don't need the full multi-gigabyte Xcode app) that gives you the compilers and utilities everything else builds on: `git`, `clang`, `make`, `gcc`, header files, and more. It's also a hard prerequisite for Homebrew and countless other dev tools.

## Installing the tools

There's one command to remember:

```bash
xcode-select --install
```

This pops up a small dialog asking you to confirm. Click **Install**, agree to the license, and macOS downloads and installs the package for you. It takes a few minutes depending on your connection.

If the tools are already installed, you'll see a message saying so, which is harmless.

## Verifying the install

Once it finishes, confirm where the tools live with `-p` (print path):

```bash
xcode-select -p
```

You should get something like `/Library/Developer/CommandLineTools`. To check the individual binaries are on your `PATH`, ask them for their versions:

```bash
git --version
gcc --version
clang --version
make --version
```

Each should print a version string. On macOS, `gcc` is actually a wrapper around Apple's `clang`, so don't be surprised if `gcc --version` mentions clang.

## What you get

Beyond the headline tools, the package bundles the C/C++ toolchain, `make`, `git`, `svn`, the linker, and the system headers that native extensions compile against. This is why installing Node modules with native bindings, Ruby gems, or Python packages with C extensions all "just work" afterwards.

## Resetting and reinstalling

Sometimes a macOS upgrade leaves the tools in a confused state and you get errors like `tool 'xcodebuild' requires Xcode`. The first thing I try is resetting the active developer directory:

```bash
sudo xcode-select --reset
```

That points `xcode-select` back at the default location. If the tools are genuinely broken or missing, remove and reinstall them. Delete the folder, then run the installer again:

```bash
sudo rm -rf /Library/Developer/CommandLineTools
xcode-select --install
```

The `rm -rf` here is safe because that directory is owned entirely by the tools package; reinstalling recreates it cleanly.

## Next steps

With the Command Line Tools in place, you've got a working compiler and `git` out of the box. From here the usual next move is installing Homebrew, which depends on exactly these tools, and from there the rest of your stack. If you run into a "command not found" after install, open a new terminal window so it picks up the updated environment.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[GitHub Permission denied (publickey)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/github-permission-denied-publickey" />
            <id>https://rocketeersapp.com/github-permission-denied-publickey</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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 <username>!`, 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to find files in Linux with the find command]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/find-files-linux-command" />
            <id>https://rocketeersapp.com/find-files-linux-command</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Finding files with find

When I need to track down a file and don't know exactly where it lives, `find` is the workhorse. It walks a directory tree recursively and matches files against the conditions you give it. The basic shape is `find <path> <conditions>`, where the path is where to start searching.

## Find by name

Search by filename with `-name`. It matches the exact name, so use shell-style wildcards (quoted, so the shell doesn't expand them first):

```bash
find . -name '*.log'
```

If case shouldn't matter, use `-iname` instead. This matches `README`, `readme` and `ReadMe`:

```bash
find . -iname 'readme*'
```

## Find by type

Limit results to files or directories with `-type`. Use `f` for regular files and `d` for directories:

```bash
find /var/www -type f -name '*.php'
find /var/www -type d -name 'cache'
```

## Find by size

The `-size` flag matches on file size. Suffix the number with `k`, `M` or `G`, and prefix it with `+` for "larger than" or `-` for "smaller than":

```bash
find . -type f -size +100M
```

This finds files larger than 100 megabytes — handy for hunting down what's eating your disk.

## Find by modification time

`-mtime` matches by how many days ago a file was last modified. `-mtime -7` means changed within the last 7 days, while `+30` means older than 30 days:

```bash
find /tmp -type f -mtime +30
```

For finer control, `-mmin` works the same way but in minutes.

## Searching a specific path

The path argument can be anything — an absolute path, a relative one, or several at once:

```bash
find /etc /opt -name '*.conf'
```

## Acting on results with -exec

Beyond listing matches, `find` can run a command on each one with `-exec`. The `{}` is replaced by the filename and `\;` ends the command:

```bash
find . -name '*.tmp' -type f -exec rm {} \;
```

For deleting specifically, the built-in `-delete` is cleaner and avoids the "[argument list too long](/argument-list-too-long)" error you'd hit with `rm *`:

```bash
find ./logs -name '*.log' -type f -delete
```

You can chain `find` with other tools too. To search the contents of every matching file, pipe the names into [grep](/search-files-grep-command):

```bash
find . -name '*.php' -exec grep -l 'TODO' {} \;
```

Combine conditions freely — they're ANDed together — and `find` becomes a precise way to locate exactly the files you're after.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[503 Service Unavailable]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/503-service-unavailable" />
            <id>https://rocketeersapp.com/503-service-unavailable</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Git fatal: not a git repository]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/git-not-a-git-repository" />
            <id>https://rocketeersapp.com/git-not-a-git-repository</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Composer out of memory (allowed memory size exhausted)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/composer-out-of-memory-allowed-memory-size-exhausted" />
            <id>https://rocketeersapp.com/composer-out-of-memory-allowed-memory-size-exhausted</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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
```]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[PHP Maximum execution time of N seconds exceeded]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/php-maximum-execution-time-exceeded" />
            <id>https://rocketeersapp.com/php-maximum-execution-time-exceeded</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to update Ubuntu from the command line]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/update-ubuntu-command-line" />
            <id>https://rocketeersapp.com/update-ubuntu-command-line</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Update vs upgrade: the part everyone confuses

The single most common mix-up with `apt` is thinking `update` installs new software. It doesn't. `apt update` only refreshes the list of available packages, while `apt upgrade` actually installs the newer versions. You almost always run them as a pair.

## Refresh the package lists

First, sync the local index of what's available from Ubuntu's repositories:

```bash
sudo apt update
```

This downloads nothing but metadata. After it runs, `apt` will often tell you how many packages can be upgraded. Nothing on your system has changed yet.

## Install the available updates

Now apply them:

```bash
sudo apt upgrade
```

`apt upgrade` installs newer versions of packages you already have. By design it won't remove anything or install brand-new dependencies. To skip the confirmation prompt, add `-y`:

```bash
sudo apt upgrade -y
```

## When upgrade isn't enough: full-upgrade

Sometimes an update needs to remove an obsolete package or pull in a new dependency, for example a new kernel. Plain `upgrade` holds those back. `full-upgrade` (the modern name for `dist-upgrade`) allows it:

```bash
sudo apt full-upgrade
```

I run this for kernel and major library updates. Both `full-upgrade` and `dist-upgrade` do the same thing.

## Updating a single package

If you only want to bump one package, name it after `install`. It upgrades just that package (and its dependencies) to the latest available version:

```bash
sudo apt install --only-upgrade nginx
```

## Clean up afterwards

Old kernels and orphaned dependencies pile up over time. Remove what's no longer needed:

```bash
sudo apt autoremove
```

This is one of the first things I do when I [reclaim disk space on Ubuntu](/reclaim-diskspace-on-ubuntu), since stale kernels eat into `/boot`.

## Upgrading to a new Ubuntu release

Everything above keeps your current release patched. To jump to a newer release, like 22.04 to 24.04, you need a different tool:

```bash
sudo do-release-upgrade
```

This is a major upgrade, so first make sure your current system is fully patched and check which release you're on with [how to check your Ubuntu version](/check-ubuntu-version-command-line). Add `-d` only if you specifically want to upgrade to a development release.

## The one-liner I actually use

Day to day, I chain the common steps together:

```bash
sudo apt update && sudo apt upgrade -y && sudo apt autoremove -y
```

That refreshes, installs everything, and tidies up in a single command.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[What is an SSL certificate chain]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/what-is-an-ssl-certificate-chain" />
            <id>https://rocketeersapp.com/what-is-an-ssl-certificate-chain</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[SQLSTATE[HY000] [1045] Access denied for user]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/sqlstate-hy000-1045-access-denied-for-user" />
            <id>https://rocketeersapp.com/sqlstate-hy000-1045-access-denied-for-user</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

In a Laravel application the full message reads something like:

```bash
SQLSTATE[HY000] [1045] Access denied for user 'forge'@'localhost' (using password: YES)
```

`using password: YES` means a password was sent but rejected. `using password: NO` means no password was sent at all, often a sign the value is empty in your config.

## Why do I see this error

MySQL refused the login. There are only a handful of reasons:

- The username or password is wrong.
- The user exists but is not allowed to connect from this host (`'user'@'localhost'` versus `'user'@'%'`).
- The user has no privileges granted on the target database.
- Your application is reading the wrong credentials, an old `.env`, a cached config, or the wrong environment.

## Solution

First, confirm the credentials in your `.env` are correct:

```ini
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=my_app
DB_USERNAME=forge
DB_PASSWORD=secret
```

Test those exact values directly against MySQL to rule out the application entirely:

```bash
mysql -u forge -p -h 127.0.0.1 my_app
```

If that fails too, the problem is in MySQL, not Laravel. Create or fix the user and grant it access:

```sql
CREATE USER 'forge'@'localhost' IDENTIFIED BY 'secret';
GRANT ALL PRIVILEGES ON my_app.* TO 'forge'@'localhost';
FLUSH PRIVILEGES;
```

Note the host part. A user created as `'forge'@'localhost'` cannot connect over TCP to `127.0.0.1`, MySQL treats those as different hosts. If your app connects via `127.0.0.1`, grant access to `'forge'@'127.0.0.1'` (or `'%'` for any host).

If the direct `mysql` login works but Laravel still fails, your application is reading stale config. Clear it:

```bash
php artisan config:clear
```

A cached config (`php artisan config:cache`) freezes whatever values were present at cache time, so re-run it after changing the `.env`. See [environment variables in Laravel](/environment-variables-laravel) for how that loading works.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to remove untracked files in Git (git clean)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/git-remove-untracked-files" />
            <id>https://rocketeersapp.com/git-remove-untracked-files</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Preview first with a dry run

`git clean` permanently deletes files that Git is not tracking. Untracked files were never committed, so there is no history to recover them from. **Always preview** with `-n` (dry run) before deleting anything:

```bash
git clean -n
```

This lists what *would* be removed without touching anything.

## Remove untracked files

When you are happy with the preview, `-f` (force) actually deletes them. Git requires the flag as a safety measure:

```bash
git clean -f
```

To also remove untracked **directories**, add `-d`:

```bash
git clean -fd
```

## Including ignored files

By default `git clean` leaves files listed in `.gitignore` alone (your `node_modules`, build output, `.env`, and so on). To remove ignored files as well, add `-x`:

```bash
git clean -fdx
```

To remove **only** ignored files and keep other untracked ones, use `-X` (uppercase):

```bash
git clean -fdX
```

## Interactive mode

If you want to choose file by file, use interactive mode:

```bash
git clean -i
```

## clean vs reset

These two solve different problems:

- `git clean` removes **untracked** files (files Git has never recorded).
- [git reset --hard](/git-reset-hard) discards changes to **tracked** files.

To stop tracking a file that was committed by mistake but keep it on disk, that is a different task again, see [Removing tracked files in Git that should have been ignored](/removing-tracked-files-in-git-that-should-have-been-ignored).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to find and optimize slow MySQL queries]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/mysql-query-optimization" />
            <id>https://rocketeersapp.com/mysql-query-optimization</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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).

## 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.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Git: Updates were rejected (non-fast-forward)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/git-updates-were-rejected-non-fast-forward" />
            <id>https://rocketeersapp.com/git-updates-were-rejected-non-fast-forward</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[SQLSTATE[42S02] Base table or view not found]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/sqlstate-42s02-base-table-or-view-not-found" />
            <id>https://rocketeersapp.com/sqlstate-42s02-base-table-or-view-not-found</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

In Laravel the message reads:

```bash
SQLSTATE[42S02]: Base table or view not found: 1146 Table 'my_app.users' does not exist
```

The query is valid SQL, but the table it names isn't present in the database you're connected to.

## Why do I see this error

- Your migrations haven't run, so the table was never created.
- You're connected to the wrong database (wrong `DB_DATABASE`, or a fresh/empty database).
- The table name has a typo, or you renamed it without updating the model's `$table`.
- A migration failed halfway and the table was rolled back or never committed.

## Solution

### Run your migrations

The most common fix, especially on a fresh checkout or a new environment:

```bash
php artisan migrate
```

To check what has and hasn't run:

```bash
php artisan migrate:status
```

On a development database you can rebuild everything from scratch:

```bash
php artisan migrate:fresh
```

Be careful: `migrate:fresh` **drops every table first**. Never run it against a database with data you care about.

### Confirm you're on the right database

Verify your connection settings point where you think they do:

```ini
DB_DATABASE=my_app
```

Then check the table really exists there:

```sql
SHOW TABLES FROM my_app;
```

If the value changed recently, clear any cached config so Laravel reads the new one:

```bash
php artisan config:clear
```

### Check the model's table name

If Eloquent guesses the wrong table name, set it explicitly:

```php
class Customer extends Model
{
    protected $table = 'customers';
}
```

See [environment variables in Laravel](/environment-variables-laravel) for how the connection is configured.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[SQLSTATE[HY000] [2002] Connection refused]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/sqlstate-hy000-2002-connection-refused" />
            <id>https://rocketeersapp.com/sqlstate-hy000-2002-connection-refused</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## About the error

You'll see one of these two variants:

```bash
SQLSTATE[HY000] [2002] Connection refused
SQLSTATE[HY000] [2002] No such file or directory
```

This is different from a [1045 Access denied](/sqlstate-hy000-1045-access-denied-for-user) error. There the server rejected your login. Here the server was never reached at all, so it never got as far as checking a username or password.

## Why do I see this error

- MySQL isn't running.
- `DB_HOST` or `DB_PORT` points to the wrong place.
- The classic mix-up: `Connection refused` over TCP versus `No such file or directory` over a socket.

The two messages are a useful hint:

- **Connection refused** means the app tried to reach MySQL over TCP (a host and port) and nothing was listening there.
- **No such file or directory** means the app tried to use a Unix socket and the socket file wasn't where it expected.

## Solution

First, make sure MySQL is actually running:

```bash
systemctl status mysql
systemctl start mysql
```

Then check your host and port. Using `127.0.0.1` forces a TCP connection, while `localhost` makes MySQL use a Unix socket, which is a frequent source of confusion:

```ini
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
```

Confirm MySQL is listening on that port:

```bash
ss -tlnp | grep 3306
```

Test the connection independently of your app:

```bash
mysql -u forge -p -h 127.0.0.1 -P 3306
```

If you're running MySQL in **Docker**, `127.0.0.1` from inside another container points at the container itself, not the database. Use the service name from your compose file as the host instead:

```ini
DB_HOST=mysql
```

After changing anything in `.env`, clear the cached config:

```bash
php artisan config:clear
```]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to increase the PHP memory limit]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/increase-php-memory-limit" />
            <id>https://rocketeersapp.com/increase-php-memory-limit</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[`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).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to kill the process running on a port in Linux]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/kill-process-on-port-linux" />
            <id>https://rocketeersapp.com/kill-process-on-port-linux</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## When a port is stuck

You try to start your dev server and Linux tells you the port is taken. Before you can free it, you need to find which process is holding the port, get its PID (process ID), and stop it. Here's how I do it. If you hit the classic [address already in use](/address-already-in-use-port) error, this is exactly the fix.

## Find the process listening on the port

My favorite is `lsof`, which lists open files (and sockets count as files):

```bash
lsof -i :3000
```

The `-i :3000` filters to the network connection on port 3000. The output's `PID` column is the number you need. If you prefer the modern socket tool, `ss` works too:

```bash
ss -ltnp 'sport = :3000'
```

Those flags read as `-l` listening sockets, `-t` TCP, `-n` numeric ports (don't resolve names), `-p` show the owning process. The older `netstat` does the same job if it's installed:

```bash
sudo netstat -tulpn | grep :3000
```

Here `-t` is TCP, `-u` UDP, `-l` listening, `-p` process, `-n` numeric. You usually need `sudo` to see PIDs owned by other users.

## Kill the process

Once you have the PID, send it a termination signal:

```bash
kill 12345
```

Plain `kill` sends `SIGTERM`, which asks the process to shut down gracefully. That's what you want most of the time. If it ignores you and refuses to die, escalate to `SIGKILL`, which the process cannot catch or ignore:

```bash
kill -9 12345
```

Reach for `-9` only when a normal `kill` doesn't work, because the process won't get a chance to clean up.

## The one-liners I actually use

Looking up the PID and typing it back in gets old fast. `lsof -t` prints only the PID, so you can feed it straight into `kill`:

```bash
kill $(lsof -t -i:3000)
```

Even shorter, `fuser` can find and kill in a single command:

```bash
fuser -k 3000/tcp
```

The `-k` flag kills every process attached to that port, and `3000/tcp` scopes it to TCP port 3000. To send `SIGKILL` instead, add `-9`:

```bash
fuser -k -9 3000/tcp
```

## A quick word of caution

Before you kill blindly, glance at what's actually using the port, especially with `sudo`. Killing the wrong PID can take down a database or a service you didn't mean to touch. When in doubt, run `lsof -i :3000` first and read the command name before pulling the trigger.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to generate SSH keys with ssh-keygen]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/generate-ssh-keys-ssh-keygen" />
            <id>https://rocketeersapp.com/generate-ssh-keys-ssh-keygen</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Why you want SSH keys

Typing a password every time you connect to a server gets old fast, and passwords are the weaker link anyway. SSH keys give you a passwordless, far more secure login: you keep a private key on your machine, hand the matching public key to the server, and SSH proves you own the pair without ever sending a secret over the wire. Once they're set up, [connecting to a server](/connect-to-server-ssh-command) and pushing to Git just work.

## Generating a key with ssh-keygen

`ssh-keygen` ships with every Linux distro and macOS. The modern, recommended command is:

```bash
ssh-keygen -t ed25519 -C "you@example.com"
```

- `-t ed25519` picks the Ed25519 algorithm. It's fast, secure, and produces short keys. Use this unless something old refuses to accept it.
- `-C "you@example.com"` adds a comment, usually your email, so you can recognize the key later in a list of authorized keys.

If you're stuck talking to ancient hardware or legacy software that doesn't speak Ed25519, fall back to RSA with a large key size:

```bash
ssh-keygen -t rsa -b 4096 -C "you@example.com"
```

## Passphrase or no passphrase

`ssh-keygen` asks for a passphrase. My advice: set one. It encrypts the private key on disk, so a stolen laptop doesn't hand over your servers. The minor inconvenience of typing it is solved by `ssh-agent` (below), which remembers it for your session. Press Enter twice for no passphrase only on throwaway or fully automated keys.

## Where the keys live

By default the keys land in `~/.ssh/`:

- `~/.ssh/id_ed25519` is your **private** key. Never share it, never commit it, never copy it off the machine.
- `~/.ssh/id_ed25519.pub` is your **public** key. This is the safe one you give to servers and GitHub.

You can view the public key any time:

```bash
cat ~/.ssh/id_ed25519.pub
```

## Copying the public key to a server

The cleanest way to authorize your key on a server is `ssh-copy-id`. It appends your public key to the server's `~/.ssh/authorized_keys` for you:

```bash
ssh-copy-id user@host
```

It'll ask for your password one last time. After that, `ssh user@host` logs you in with the key. If `ssh-copy-id` isn't available (it's missing on some macOS setups), you can do it manually:

```bash
cat ~/.ssh/id_ed25519.pub | ssh user@host "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
```

If the server still asks for a password or rejects the key, the [SSH permission denied (publickey) guide](/ssh-permission-denied-publickey) walks through the usual causes.

## Adding the key to ssh-agent

`ssh-agent` holds your decrypted key in memory so you only type the passphrase once per session. Start it and add your key:

```bash
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
```

On macOS, store the passphrase in the Keychain so it persists across reboots:

```bash
ssh-add --apple-use-keychain ~/.ssh/id_ed25519
```

## Adding the key to GitHub

To push and pull over SSH, GitHub needs your public key. Copy it to your clipboard:

```bash
# macOS
pbcopy < ~/.ssh/id_ed25519.pub

# Linux (X11)
xclip -sel clip < ~/.ssh/id_ed25519.pub
```

Then go to GitHub, open **Settings, SSH and GPG keys, New SSH key**, paste it, and save. Verify it works:

```bash
ssh -T git@github.com
```

You should see a greeting with your username. If you instead hit `Permission denied (publickey)`, the [GitHub publickey error guide](/github-permission-denied-publickey) covers the fixes. Once this is green, your key handles servers and Git for good.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to clean up Docker with prune (images, volumes, system)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/docker-prune" />
            <id>https://rocketeersapp.com/docker-prune</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## See what Docker is using

Before deleting anything, check where the space has gone:

```bash
docker system df
```

This breaks down disk usage by images, containers, local volumes and build cache.

## Prune everything unused at once

`docker system prune` removes stopped containers, unused networks, dangling images and the build cache in one go:

```bash
docker system prune
```

It asks for confirmation and, by default, only removes **dangling** images (layers no longer referenced by any tag). To also remove every image not used by a running container, add `-a`:

```bash
docker system prune -a
```

Volumes are **not** touched unless you ask, because they usually hold data you care about. To include them:

```bash
docker system prune -a --volumes
```

Be careful with that last one: it deletes any volume not attached to a running container.

## Prune one resource type

For finer control, prune a single category:

```bash
docker container prune   # remove all stopped containers
docker image prune       # remove dangling images
docker image prune -a    # remove all unused images
docker volume prune      # remove unused volumes
docker network prune     # remove unused networks
docker builder prune     # clear the build cache
```

## Skip the confirmation prompt

In scripts or cron jobs, add `-f` to skip the "Are you sure?" prompt:

```bash
docker system prune -af
```

If you reached for prune because a server filled up, the disk pressure may be coming from more than Docker, see [No space left on device on Ubuntu](/no-space-left-on-device-ubuntu).]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to forward ports with SSH tunneling]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/ssh-tunnel-port-forwarding" />
            <id>https://rocketeersapp.com/ssh-tunnel-port-forwarding</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## What SSH tunneling is for

SSH can do more than give you a shell: it can carry other connections through its encrypted channel. I lean on this constantly to reach services that aren't exposed to the internet, like a database bound to `localhost` on a remote server. If you're new to the `ssh` command itself, start with [connecting to a server with ssh](/connect-to-server-ssh-command).

There are three kinds of forwarding worth knowing.

## Local forwarding (-L)

Local forwarding makes a remote service appear on a port of your own machine. The syntax is `-L local_port:target_host:target_port`:

```bash
ssh -L 8080:localhost:80 jane@app.example.com
```

Here `localhost:80` is resolved **from the server's perspective**, so this exposes the server's local web service on `http://localhost:8080` on your laptop.

The classic use is a database. Say MySQL on the server listens on `3306` but isn't reachable from outside. Tunnel it:

```bash
ssh -L 3306:localhost:3306 jane@app.example.com
```

Now point your local MySQL client at `127.0.0.1:3306` and you're talking to the remote database over SSH. If `3306` is already busy locally, just pick another local port like `-L 13306:localhost:3306`.

## Remote forwarding (-R)

Remote forwarding is the reverse: it exposes a port on **your** machine to the server. The syntax is `-R remote_port:target_host:target_port`. This is handy for letting a remote box reach a service running on your laptop, for example a webhook receiver during development:

```bash
ssh -R 9000:localhost:3000 jane@app.example.com
```

Now connections to port `9000` on the server are forwarded to your local port `3000`.

## Dynamic forwarding (-D)

Dynamic forwarding turns SSH into a local SOCKS proxy, routing whatever you send through it out via the server. Point a browser or tool at the SOCKS port and your traffic exits from the server:

```bash
ssh -D 1080 jane@app.example.com
```

Configure your application to use `127.0.0.1:1080` as a SOCKS5 proxy. This is great for reaching internal services that are only allowed from the server's network.

## The -N and -f flags

When you're tunnelling, you usually don't want an interactive shell as well. Two flags help:

- `-N` tells SSH not to run a remote command (just forward), so you don't get a shell prompt.
- `-f` sends SSH to the background after authenticating.

Combined, they give you a clean background tunnel:

```bash
ssh -fN -L 3306:localhost:3306 jane@app.example.com
```

That command sets up the MySQL tunnel and returns your prompt immediately. To shut it down later, find and kill it:

```bash
pkill -f "3306:localhost:3306"
```

Once you've got the `-L`, `-R`, and `-D` shapes memorised, you can punch a secure path to almost any service without exposing it to the open internet.]]>
            </summary>
                                    <updated>2026-06-23T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to setup OpenClaw securely on your own VPS]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/setup-openclaw-vps-securely" />
            <id>https://rocketeersapp.com/setup-openclaw-vps-securely</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2026-02-04T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to get A+ grade SSL using Cloudflare]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/a-plus-grade-ssl-using-cloudflare" />
            <id>https://rocketeersapp.com/a-plus-grade-ssl-using-cloudflare</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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 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.

## 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.]]>
            </summary>
                                    <updated>2024-09-24T19:16:29+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to rename a MySQL database]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/rename-mysql-database" />
            <id>https://rocketeersapp.com/rename-mysql-database</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[The most important step: To get going with this, first [create a full backup of your MySQL database](/backup-mysql-databases-single-file) to make sure you don't loose any data!

## Create a newly named database

This step involves creating the database you want to rename the old database to:

```sql
CREATE DATABASE `NEW_DATABASE_NAME`;
```

## Dump & import

Dump and import the old database into your new database:

```bash
mysqldump -u USERNAME -p OLD_DATABASE_NAME | mysql -u USERNAME -p NEW_DATABASE_NAME
```

## Drop the old database

If your really sure, you can (with the backup on hand) remove the old database safely:

```sql
DROP DATABASE `OLD_DATABASE_NAME`
```]]>
            </summary>
                                    <updated>2024-09-25T07:47:45+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How many CPU cores on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/how-many-cpu-cores-on-ubuntu" />
            <id>https://rocketeersapp.com/how-many-cpu-cores-on-ubuntu</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[Simple little command to get all the CPU cores active on your machine running on Ubuntu:

```bash
nproc --all
```

That's it!]]>
            </summary>
                                    <updated>2024-09-02T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How much RAM memory on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/how-much-memory-on-ubuntu" />
            <id>https://rocketeersapp.com/how-much-memory-on-ubuntu</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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}'
```]]>
            </summary>
                                    <updated>2024-09-02T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Import MySQL database without timezone difference]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/mysql-import-without-timezone-difference" />
            <id>https://rocketeersapp.com/mysql-import-without-timezone-difference</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Why is that?

This is because there is a timezone difference between your computer (probably in your local time) and the server (probably in UTC as it should), while your timestamp columns don't have a timezone specified.
As it is your personal computer, you want the time to display in your local timezone. But when developing, you should want everything to be in `UTC` timezone to prevent al sorts of problems (like this one!) and to easily
convert and display the correct time(zone) to your users.

## MySQL timezone setting

When you this query in MySQL:

```sql
SELECT @@global.time_zone;
```

You probably get the default value `SYSTEM` returned, this means MySQL uses the timezone your computer is set to.

## How can you fix this?

Before import a database, make sure to change the timezone to `UTC` by running:

```bash
sudo mysql -e "SET GLOBAL time_zone = '+0:00';"
```

While this fixes it for your import, this value is being reset when you restart the MySQL instance. To make this change permanent, change it in `/etc/mysql/my.cnf`:

```bash
sudo nano /etc/mysql/my.cnf # linux
sudo nano /opt/homebrew/etc/my.cnf # macos
```

Add or change under the section `[mysqld]` the value for `default-time-zone`:

```bash
default-time-zone = "+0:00"
```

To make the change active, restart MySQL:

```bash
sudo service mysql restart # linux
brew services restart mysql # macos
```]]>
            </summary>
                                    <updated>2024-05-02T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Zero downtime deployments using PHP-FPM and nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/zero-downtime-php-deployments" />
            <id>https://rocketeersapp.com/zero-downtime-php-deployments</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2024-10-08T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Environment variables in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/environment-variables-laravel" />
            <id>https://rocketeersapp.com/environment-variables-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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
```]]>
            </summary>
                                    <updated>2024-03-13T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[419 Page Expired error in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/419-page-expired-laravel" />
            <id>https://rocketeersapp.com/419-page-expired-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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')
    }
});
```

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).]]>
            </summary>
                                    <updated>2024-09-18T15:36:55+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to clear cache in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/clear-cache-laravel" />
            <id>https://rocketeersapp.com/clear-cache-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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"
```]]>
            </summary>
                                    <updated>2024-02-15T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to check Ubuntu version]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/ubuntu-version" />
            <id>https://rocketeersapp.com/ubuntu-version</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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"
```]]>
            </summary>
                                    <updated>2024-02-08T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[413 Request Entity Too Large in nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/413-request-entity-too-large" />
            <id>https://rocketeersapp.com/413-request-entity-too-large</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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

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
```]]>
            </summary>
                                    <updated>2024-09-18T15:35:52+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Configure Content Security Policy with nonce using nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/content-security-policy" />
            <id>https://rocketeersapp.com/content-security-policy</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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
<script nonce="NGINX_CSP_NONCE">
/**
 * Your inline script
 */
</script>
```

### 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/).]]>
            </summary>
                                    <updated>2024-01-21T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Server Side Includes (SSI) in nginx]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/server-side-includes-ssi-nginx" />
            <id>https://rocketeersapp.com/server-side-includes-ssi-nginx</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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
<!--#include virtual="/path/to/header.html" -->
```

2. Date and Time Stamps: You can insert the current date and time into your web pages.

```html
<!--#echo var="DATE_LOCAL" -->
```

3. Conditional Statements: SSI supports simple conditional statements, allowing you to include or exclude content based on certain conditions.

```html
<!--#if expr="${QUERY_STRING} = 1" -->
Content for query string 1.
<!--#else -->
Content for other cases.
<!--#endif -->
```

4. Variable Setting and Displaying: You can set variables and display their values.

```html
<!--#set var="pageTitle" value="My Page" -->
<title><!--#echo var="pageTitle" --></title>
```

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
```

### 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
```]]>
            </summary>
                                    <updated>2023-12-07T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to install PostgreSQL with pgvector on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/install-postgresql-pgvector-ubuntu" />
            <id>https://rocketeersapp.com/install-postgresql-pgvector-ubuntu</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[We assume you already have provisioned a Ubuntu server, ready to get going.

## Install PostgreSQL

If you haven't already installed PostgreSQL on your server, execute these commands to install it from the official repository:

```bash
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
```

## Install pgvector

When PostgreSQL is installed, run the following command:

```bash
sudo apt install postgresql-17-pgvector
```

## Enable the pgvector extension

The last step to turn PostgreSQL in a vector database, run the following query as the postgres user:

```bash
sudo -u postgres psql # run as postgres user
```

```sql
CREATE EXTENSION vector; # create the vector extension
```

## Start PostgreSQL automatically (optional)

Configure PostgreSQL to start automatically after booting your server:

```bash
sudo systemctl enable postgresql
sudo service postgresql start
```]]>
            </summary>
                                    <updated>2025-01-06T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to install Composer packages locally]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/install-composer-packages-locally" />
            <id>https://rocketeersapp.com/install-composer-packages-locally</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Developing Composer packages locally

During development I regularly need to install Composer packages from a Git repository on my local machine. When your developing a Composer package, you usually want to see the changes you've made to a package directly when using or testing it on a PHP project.

This can be done using the symlink feature, where Composer is instructed to symlink the repository from your local drive. This also means you have to make sure the package is in the same folder as the PHP project your installing the package to.

Sometimes I need to do this multiple times a day. Because it is a little anoying to do this every time manually, while also tending to forget the JSON structure that's needed. That's why I put in the effort to create a sweet Bash function (or alias) for this.

## What does it do

The bash function handles some common situations gracefully:

-   It prevents exection in the wrong directory (a folder without composer.json)
-   It removes the current vendor folder and composer.lock file to prevent conflicted packages when installing the local package
-   It returns the changes on files done by the alias back to its prior state, so you won't commit the wrong composer.json or composer.lock file by accident

Bottom line, it takes the pain away of setting it all up manually!

## The bash function

The function checks for an existing composer.json, leverages the composer binary to change the configuration of the composer.json, removes the current vendor and composer.lock to discard any conflicting situations, requires the package to install it using the configured symlink and finally removes the changes made from the Git repository like it's never been changed. But by still keeping the `vendor` folder in tact, the package will be symlinked locally.

```bash
package() {

    if [[ ! -f "composer.json" ]]; then
        echo "composer.json not found"
        exit 1
    fi

    VENDOR="${2:-vormkracht10}"

    composer config repositories.$1 '{"type": "path", "url": "../'$1'", "options": {"symlink": true}}' --file composer.json

    rm -Rf ./vendor
    rm -Rf ./composer.lock

    composer require $VENDOR/$1 @dev

    git checkout -- composer.json
    git checkout -- composer.lock
}
```

## Usage

You can use the Bash function in two ways:

You can change the `vormkracht10` string to the vendor or organization of your preference. When you're needing packages mostly from this vendor, it keeps the command nice and short:

```bash
package [package-name] # e.g. package laravel-ok
```

If you do need to overwrite the vendor, you can do this by setting the second parameter to a custom vendor:

```bash
package [package-name] [vendor] # e.g. package laravel-ok vormkracht10
```]]>
            </summary>
                                    <updated>2023-11-18T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Complete list of Laravel events]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/laravel-events" />
            <id>https://rocketeersapp.com/laravel-events</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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
```]]>
            </summary>
                                    <updated>2024-02-15T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Error in the HTTP2 framing layer]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/error-in-the-http2-framing-layer" />
            <id>https://rocketeersapp.com/error-in-the-http2-framing-layer</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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` available in the APT repositories of Ubuntu.

## 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.]]>
            </summary>
                                    <updated>2023-09-04T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Laravel Valet]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/laravel-valet" />
            <id>https://rocketeersapp.com/laravel-valet</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2023-04-26T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Install PHP memcached extension on macOS]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/install-php-memcached-extension-on-macos" />
            <id>https://rocketeersapp.com/install-php-memcached-extension-on-macos</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Install using Homebrew and PECL

If you have PHP installed using [Homebrew](https://brew.sh), you previously could also install PHP extensions using `brew` itself. But things have changed, and you need to use `pecl` to install additional extensions.

When you need to install `memcached`, you need to install this including the dependencies `zlib` and `libmemcached`:

```bash
brew install memcached libmemcached zlib pkg-config
```

Then you can initiate the install via `pecl`:

```bash
pecl install memcached
```

This will ask multiple questions, where you will only need to answer the question about the path of zlib:

```bash
zlib directory [no] :
```

This path depends on the processor of your Mac, there's a difference between Apple silicon machines on Mac Intel. The path is per type of machine:

## ZLIB path

```bash
# On Apple Silicon
/opt/homebrew/opt/zlib

# On Mac Intel
/usr/local/opt/zlib
```

When given this path, the `pecl` install should complete successfully.]]>
            </summary>
                                    <updated>2023-03-17T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Creating an encrypted cookie value in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/creating-an-encrypted-cookie-value-in-laravel" />
            <id>https://rocketeersapp.com/creating-an-encrypted-cookie-value-in-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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
        );
    }
}
```]]>
            </summary>
                                    <updated>2023-03-08T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to upgrade MySQL 5.7 to 8.0 on Ubuntu]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/upgrade-mysql-5-7-to-8-0-ubuntu" />
            <id>https://rocketeersapp.com/upgrade-mysql-5-7-to-8-0-ubuntu</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[### Backup existing databases (don't skip)

Don't skip this part, it can save you a lot of trouble when something goes wrong!

[Backup the databases in separate files](/backup-mysql-databases-separate-files) or choose to [backup your databases in a single file](/backup-mysql-databases-single-file). Possibly the latter is easier when needing to import the backup and importing it all at once.

### Remove current MySQL version

```bash
sudo apt purge mysql-*
```

### Install essential packages and add apt key

```bash
sudo apt install -y dirmngr # essential package for adding apt key
sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 467B942D3A79BD29
```

### Add MySQL 8.0 repository source

```bash
echo "deb http://repo.mysql.com/apt/ubuntu $(lsb_release -sc) mysql-8.0" | sudo tee /etc/apt/sources.list.d/mysql-8.0.list
```

### Update apt repositories & install MySQL 8.0

```bash
sudo apt-get update
sudo apt-get install -y mysql-server mysql-client
```

### Check if MySQL 8.0 is installed

When everything goes well, you're done. MySQL version 8.0 is installed. You can verify this by checking the status of the MySQL daemon and the MySQL version:

```
sudo service mysql status
```

This should output that MySQL is `active (running)`. To make sure you're on the new version, check the MySQL version:

```bash
sudo mysql --version
```

Should show something like:

```
mysql  Ver 8.0.31 for Linux on x86_64 (MySQL Community Server - GPL)
```

### Help! MySQL doesn't start anymore

Check the error logs of MySQL to find the cause, most of the times it provides a clear message and it's probably because of some deprecated configuration inside in one of the `/etc/mysql` config files.

An example of an error message I have got in the past. Removing `NO_AUTO_CREATE_USER` from the `sql_mode` fixed the issue:

```
2023-01-12T09:21:12.834467Z 0 [ERROR] [MY-000077] [Server] /usr/sbin/mysqld: Error while setting value 'NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION' to 'sql_mode'.
```]]>
            </summary>
                                    <updated>2023-01-12T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Backup MySQL databases in separate files]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/backup-mysql-databases-separate-files" />
            <id>https://rocketeersapp.com/backup-mysql-databases-separate-files</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## MySQL backup bash script

A clear and simple bash script to export all MySQL databases on your server into separate `.SQL` files.

With inline comments, this is the bash script to export all databases into separate files:

```bash
# MySQL username & password
USER=""
PASSWORD=""

# MySQL dump options
OPTIONS="--add-drop-table --extended-insert --single-transaction --skip-comments"

# Skip exporting these databases, separated by "|"
SKIP="Database|mysql|sys|information_schema|performance_schema"

# Get all databases on server
DATABASES=`
    echo "SHOW DATABASES;" |
    mysql --user="$USER" --password="$PASSWORD" |
    grep -v -E "^(${SKIP})$"
`

# Loop through databases
for DATABASE in $DATABASES
do
    # Export database into separate file
    mysqldump \
        --user="$USER" \
        --password="$PASSWORD" \
        $OPTIONS \
        "$DATABASE" \
        > ./$DATABASE.sql
done
```

## How to use

You can use this by copying the script, save it in an `backup.sh` file and then execute `chmod +x ./backup.sh` to give the file execution permissions.

Execute the script with `./backup.sh`.]]>
            </summary>
                                    <updated>2023-01-12T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Backup MySQL databases in single file]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/backup-mysql-databases-single-file" />
            <id>https://rocketeersapp.com/backup-mysql-databases-single-file</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## MySQL backup bash script

A clear and simple bash script to export all MySQL databases on your server into separate `.SQL` files.

With inline comments, this is the bash script to export all databases into separate files:

```bash
# MySQL username & password
USER=""
PASSWORD=""

# MySQL dump options
OPTIONS="--add-drop-table --extended-insert --single-transaction --skip-comments"

mysqldump --user="$USER" --password="$PASSWORD" $OPTIONS --all-databases > ./databases.sql
```

## How to use

You can use this by copying the script, save it in an `backup.sh` file and then execute `chmod +x ./backup.sh` to give the file execution permissions.

Execute the script with `./backup.sh`.]]>
            </summary>
                                    <updated>2024-09-18T15:38:08+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Backup MySQL databases except system databases in a single file]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/backup-mysql-databases-except-system" />
            <id>https://rocketeersapp.com/backup-mysql-databases-except-system</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## MySQL backup bash script

A clear and simple bash script to export all MySQL databases on your server into a single backup file, excluding databases you don't want to backup.

With inline comments, this is the bash script to export all databases into a single file with exception of certain databases:

```bash
# MySQL username & password
USER=""
PASSWORD=""

# MySQL dump options
OPTIONS="--add-drop-table --extended-insert --single-transaction --skip-comments"

# Skip exporting these databases, separated by "|"
SKIP="Database|mysql|sys|information_schema|performance_schema"

# Get all databases on server, except the skipped ones
DATABASES=`
    echo "SHOW DATABASES;" |
    mysql --user="$USER" --password="$PASSWORD" |
    grep -v -E "^(${SKIP})$" |
    tr '\n' ' '
`

# Export database into a single file
mysqldump \
    --user="$USER" \
    --password="$PASSWORD" \
    $OPTIONS \
    --databases $DATABASES \
    > ./databases.sql
```

## How to use

You can use this by copying the script, save it in an `backup.sh` file and then execute `chmod +x ./backup.sh` to give the file execution permissions.

Execute the script with `./backup.sh`.]]>
            </summary>
                                    <updated>2024-09-18T15:17:39+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Best practices for sending email]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/best-practices-sending-email" />
            <id>https://rocketeersapp.com/best-practices-sending-email</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## First: Optimize deliverability

First things first, make sure your [sending email technically correct following these tips](improving-email-deliverability). This way you'll make sure email is sent the right way your hard work on writing these emails isn't for nothing.

## Keep your design simple

Compatibility with email clients is one of the most anoying things for sending email. It's the worst when you are designing a beautiful email or newsletter and seeing it destroyed by that corporately required Microsoft Outlook 2010 version.

One way or another, designing emails is a pain. It still is possible, but brace yourself. So advice is, don't make it too difficult and keep emails calm and easy.

## Use inline styles for CSS

For compatibility with all these different email clients it is advised to inline all your CSS styles. Using inline styles you can be sure that all styles are applied on your HTML elements. Because some clients strip `<head>` and `<link>` tags from the email.

It's best to automate the inlining of styles in your code, just before sending the email. So you can still develop the email like you would using stylesheets and CSS selectors. This can be done using an [open source package for inlining CSS](https://github.com/topics/inline-css).

Doing this manually is also an option, you can copy and paste your HTML in [this tool from Mailchimp](https://templates.mailchimp.com/resources/inline-css/) and it converts your HTML and separate CSS selectors to HTML with inline style attributes.

## Create bulletproof buttons

[Buttons.cm](https://buttons.cm) is an indispensable tool when creating buttons for your emails. This tool provides all the necessary legacy code that is needed for all of the different variaty of Microsoft Outlook versions.

## Compatible background images

Background images are another crazy difficult thing to achieve across email clients, also for this special feature you can use a HTML generator that creates cross compatible [image backgrounds in emails](https://backgrounds.cm).

## 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 a preheader text

A lot of email clients show an intro text right below the subject line to preview some of the contents of an email. This way the reader gets more information about what's inside the email. When you don't add a preheader text, the email client can show some random piece of text inside your email message which is not very professional looking.

Using this snippet of HTML, you can add the intro text carefully inside the `<div>` to only be shown as a preheader text and not inside the email itself:

```html
<div
    style="font-size:0px;line-height:1px;mso-line-height-rule:exactly;display:none;max-width:0px;max-height:0px;opacity:0;overflow:hidden;mso-hide:all;"
>
    <!-- Add 85-100 Characters of Preheader Text Here -->
</div>
```

## 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.]]>
            </summary>
                                    <updated>2024-09-18T15:35:31+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Improving email deliverability]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/improving-email-deliverability" />
            <id>https://rocketeersapp.com/improving-email-deliverability</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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.]]>
            </summary>
                                    <updated>2022-12-06T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to optimize web application security]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/optimize-web-application-security" />
            <id>https://rocketeersapp.com/optimize-web-application-security</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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 `<script>`, `<link>`, `<style>`, `<img>`, `<iframe>`, `<audio>`, `<video>` or `<object>` HTML element the browser can verify this with the HTTP header and determine if it can be executed safely.

[Read more about nonce on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce)

## Security.txt

When a security risk is discovered in your web application you would likely get to know about it as fast and discretely as possible. It happens often that independent security researchers discover risks on an web service, but they often lack the channels to disclose them properly to the responsible party. As a result security issues may be left unreported and unknown to the owner of the web service. To fix this problem, the standard for security.txt has been created, where security researchers can find the information to communicate and disclose security vulnerabilities securely.

[Read more about security.txt](https://securitytxt.org)

<!-- ## X-Frame-Options

## X-Content-Type-Options

## Referrer-Policy -->]]>
            </summary>
                                    <updated>2022-12-06T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Disable CSRF in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/disable-csrf-in-laravel" />
            <id>https://rocketeersapp.com/disable-csrf-in-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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',
];
```]]>
            </summary>
                                    <updated>2022-12-06T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Change casing of file or directory in Git]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/change-casing-of-file-or-directory-in-git" />
            <id>https://rocketeersapp.com/change-casing-of-file-or-directory-in-git</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.

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
```]]>
            </summary>
                                    <updated>2022-11-29T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Logging in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/laravel-logging" />
            <id>https://rocketeersapp.com/laravel-logging</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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);
```]]>
            </summary>
                                    <updated>2022-11-16T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to check which Laravel version of your app is using]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/check-laravel-version" />
            <id>https://rocketeersapp.com/check-laravel-version</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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() }}
```]]>
            </summary>
                                    <updated>2022-11-16T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to run PHP files]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/run-php-files" />
            <id>https://rocketeersapp.com/run-php-files</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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();"
```]]>
            </summary>
                                    <updated>2022-11-16T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Argument list too long (Bash: /bin/rm)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/argument-list-too-long" />
            <id>https://rocketeersapp.com/argument-list-too-long</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## The error and problem

Sometimes you can encounter this error when executing a `rm ./directory/*` inside a directory to clean it up:

```bash
$ bash: /bin/rm: Argument list too long
```

## Why this happens

This is because of the `ARGS_MAX` setting in your operating systems config, which defines the maximum argument size a command can accept. The solution is to not use a wildcard in your command:

```bash
rm ./directory
```

But the downside to this, is that you're now deleting the directory as well. And most of the times you don't want this. Because then you need to recreate the directory again and you have to take in account of the persmissions the directory previously had.

## The solution

The solution is to use the `find` command, which can perform actions directly inside a directory and only on the files. This way you don't need to use a wildcard (which would introduce the same problem):

```bash
find ./directory -type f -delete
```

You can even make it more specific to only delete certain files, in this example to only delete log files:

```bash
find ./directory -name '*.log' -type f -delete
```]]>
            </summary>
                                    <updated>2024-09-18T15:39:53+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Removing tracked files in Git that should have been ignored]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/removing-tracked-files-git" />
            <id>https://rocketeersapp.com/removing-tracked-files-git</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## 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.]]>
            </summary>
                                    <updated>2022-09-27T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Disable cookies in Laravel]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/disable-cookies-in-laravel" />
            <id>https://rocketeersapp.com/disable-cookies-in-laravel</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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](https://rocketee.rs/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);
    }
}
```]]>
            </summary>
                                    <updated>2022-09-05T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Importing database in MySQL using command line]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/import-database-mysql-command-line" />
            <id>https://rocketeersapp.com/import-database-mysql-command-line</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Different options

Importing a MySQL database can be done in several ways, using a desktop app or even using the legendary phpMyAdmin!

But the most effective and fast way to import a (large) SQL file remains - as always - using the command line interface (CLI).

## Using the command line

Using the command below you can import large SQL files in the least amount of time:

```bash
mysql -u [username] -p [database] < import.sql
```

Replace `[username]`, `[database]` and `import.sql` with the correct values in your setup.]]>
            </summary>
                                    <updated>2022-08-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Export MySQL database using command line]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/export-database-mysql-command-line" />
            <id>https://rocketeersapp.com/export-database-mysql-command-line</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[The best option to export a MySQL database as quick as possible is using the command line. You can even get fancy using compression, additional parameters to customize the export format and stream directly to an external location to save it somewhere else than your local disk.

## MySQL dump command

In the most minimal way, you can dump a MySQL database using `mysqldump` and the `--add-drop-table` parameter. Using this parameter makes it more easy to importing the file on an existing database. Without it, you will get errors when the table already exists on the destination database.

```bash
mysqldump --user=[username] --password=[password] --add-drop-table [database]
```

## Compress the backup file

When exporting the database to a backup file, it is most likely you want to compress the file to a smaller file size when saving or distributing it to another destination.

To compress the file, we can use good old `gzip`. The `-9` parameter can be lowered to `-1` make the export go faster, but the exported file larger.

```bash
mysqldump --user=[username] --password=[password] --add-drop-table [database] | gzip -9
```

## Optimize the exported file

To optimize the exported file a little bit further, we can use several parameters that make the import process smoother and remove some fluf that's not needed in the final export.

In the order of the command below, we:

-   Drop tables before creating them (`--add-drop-table`)
-   Use table locking to improve import speed (`--add-locks`)
-   Remove unneeded column statistics (`--column-statistics=0`)
-   Insert all data using one insert statement per table (`--extended-insert`)
-   Prevent acces denied errors by not using tablespaces (`--no-tablespaces`)
-   Within a single transaction to prevent an inconsistent state of the database before importing (`--single-transaction`)
-   Remove comments by skipping them in the output (`--skip-comments`)
-   Define the charset used in the export (`--set-charset`)
-   Read large tables without needing enough RAM to fit the full table in memory (`--quick`)

```bash
mysqldump --user=[username] --password=[password] --add-drop-table --add-locks --column-statistics=0 --extended-insert --no-tablespaces --single-transaction --skip-comments --set-charset --quick [database] | gzip -9
```

## Dump MySQL database to external storage

When creating backups it's desirable to save it somewhere else, because a backup file on the same disk as the database can be a single point of failure. In case your storage disk is damaged, the database and backup file are gone.

[Read how to stream MySQL back-up directly to S3 bucket](/stream-mysql-backup-s3-bucket)]]>
            </summary>
                                    <updated>2022-08-30T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Disable unnecessary and unused PHP versions (FPM pools)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/disable-unused-php-fpm-pools" />
            <id>https://rocketeersapp.com/disable-unused-php-fpm-pools</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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.]]>
            </summary>
                                    <updated>2022-08-24T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Reclaim disk space on Ubuntu server]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/reclaim-diskspace-on-ubuntu" />
            <id>https://rocketeersapp.com/reclaim-diskspace-on-ubuntu</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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**]]>
            </summary>
                                    <updated>2022-08-24T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to measure TTFB (Time To First Byte)]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/ttfb" />
            <id>https://rocketeersapp.com/ttfb</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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`]]>
            </summary>
                                    <updated>2022-08-24T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[Stream MySQL backup directly to S3 bucket]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/stream-mysql-backup-s3-bucket" />
            <id>https://rocketeersapp.com/stream-mysql-backup-s3-bucket</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[## Offsite backup location

When creating backups for your database it is recommended to save the backups to an offsite storage facility. This way when you crash or delete the wrong files on your server, you'll always have the backups in place on another filesystem.

And if this is the case, then you'll probably want to know how to stream the backup directly to the external location! Because saving the file locally first would be very inefficient and also consumes a lot of diskspace.

So a simple `mysqldump` to save to local disk won't do it anymore. Let's have a look!

## How to stream the backup directly to S3

First we start the backup using `mysqldump`, I have some preferred parameters on there, but you can change this to your own preferences.

Then we pipe the data directly to `gzip` to compress the data first, we use the highest (`9`) compression to keep storage costs low as possible.

After compressing we pipe it through `pv` to make sure we don't hit any bandwidth limits, we use here a limit of 1MB (`1m`) per second. In case you want faster uploads you should increase this.

After that we can safely stream the data to S3 using the `s3cmd` command line tool, which can be installed very easily using `apt`. We use the `--acl-private` option to make sure the backup can never be accessed publicly.

```bash
# Credentials
DATABASE="Your database name here"
PASSWORD="Your database password here"
BACKUP_PATH="File path for saving on the S3 bucket here"

# Backup command
mysqldump --password=$PASSWORD --add-drop-table --column-statistics=0 --extended-insert --no-tablespaces --single-transaction --skip-comments $DATABASE |
gzip -9 |
pv -L 1m -q |
s3cmd --acl-private put - s3://$BACKUP_PATH;
```]]>
            </summary>
                                    <updated>2022-08-24T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to use different PHP versions with Laravel Valet]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/different-php-versions-laravel-valet" />
            <id>https://rocketeersapp.com/different-php-versions-laravel-valet</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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"
```]]>
            </summary>
                                    <updated>2022-08-19T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to add Swap Space on Ubuntu servers]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/add-swap-space-on-ubuntu" />
            <id>https://rocketeersapp.com/add-swap-space-on-ubuntu</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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
```]]>
            </summary>
                                    <updated>2024-09-18T15:35:19+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to optimize server performance]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/optimize-server-performance" />
            <id>https://rocketeersapp.com/optimize-server-performance</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[**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.

[Learn how to add Swap Space to Ubuntu servers](/add-swap-space-on-ubuntu)]]>
            </summary>
                                    <updated>2022-08-17T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to optimize website performance]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/optimize-website-performance" />
            <id>https://rocketeersapp.com/optimize-website-performance</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[**This article will be continuously updated with new content.**]]>
            </summary>
                                    <updated>2022-08-16T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to get top processes with highest memory usage]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/top-processes-memory" />
            <id>https://rocketeersapp.com/top-processes-memory</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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
```]]>
            </summary>
                                    <updated>2022-08-12T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to get top processes with highest CPU usage]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/top-processes-cpu" />
            <id>https://rocketeersapp.com/top-processes-cpu</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[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
```]]>
            </summary>
                                    <updated>2022-08-12T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to extract the certificate from a PFX file]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/extract-certificate-from-pfx-file" />
            <id>https://rocketeersapp.com/extract-certificate-from-pfx-file</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[For this we need the `openssl` command line tool. Using the following command you can extract the certificate from a PFX file:

```bash
openssl pkcs12 -in pfx-file.pfx -nokeys -out certificate-file.pem
```]]>
            </summary>
                                    <updated>2022-08-10T00:00:00+00:00</updated>
        </entry>
            <entry>
            <title><![CDATA[How to extract private key from PFX file]]></title>
            <link rel="alternate" href="https://rocketeersapp.com/knowledge/extract-private-key-from-pfx-file" />
            <id>https://rocketeersapp.com/extract-private-key-from-pfx-file</id>
            <author>
                <name><![CDATA[Mark van Eijk]]></name>
            </author>
            <summary type="html">
                <![CDATA[For this we need the `openssl` command line tool. Using the following command you can extract the private key from a PFX file:

```bash
openssl pkcs12 -in pfx-file.pfx -nocerts -out private-key-file.pem -nodes
```]]>
            </summary>
                                    <updated>2022-08-10T00:00:00+00:00</updated>
        </entry>
    </feed>
