Skip to content

Size the production stack

$ grim tune --auto

Under load the app answers slowly while the server still has memory to spare, or the opposite: the box starts swapping because PHP was allowed more workers than it has room for. How many workers, how much memory each may take, how large the opcache is, how much Redis may hold: these depend on the machine you rent, not on the project.

grim tune turns the performance block of grim.json into the three files the production stack mounts, and with --auto measures the server first. A profile names a class of box; explicit keys override it; grim deploy ships the result.

Usage

$ grim tune                       # render the files from grim.json (or the medium profile)
$ grim tune --profile=large       # switch the profile, keep your overrides, render
$ grim tune --auto                # measure the server, keep what differs, render
$ grim tune --auto --env=staging  # measure another environment's server

Run it from the project directory. --auto connects as the deploy user to the project's server, so the project must already be registered there.

Arguments and options

Size the production stack from grim.json performance (profile + overrides), or measure the server with --auto

Usage

grim tune [--auto] [--env ENV] [--profile PROFILE]

Options

Option Description
--auto Measure the deploy server (CPU, memory) and save what differs from the profile into grim.json
--env=ENV With --auto: which environment's server to measure (default: the project's default environment)
--profile=PROFILE Set performance.profile (small|medium|large|custom) in grim.json before rendering

What it actually does

  1. Reads the block. performance.profile is small, medium, large or custom; without a block the project is medium. Every other key overrides the profile. The flat keys older versions wrote (pm.max_children, memory_limit, opcache.memory_consumption, opcache.jit_buffer_size) still read, and are rewritten in the nested form the first time the file is saved.
  2. With --auto, measures the server. Cores from nproc, available memory from free -m, the app container's memory when it runs. The host is deploy.environments.<env>.host, then deploy.host, then the project's entry in ~/.grim/servers.json; --env names the environment, otherwise the project's default one.
  3. With --auto, calculates the pool. max_children is 70 % of the available memory divided by 50 MB per worker, kept between 5 and 100; start_servers a quarter, min_spare an eighth, max_spare a half; resources.app.cpus the core count. Only the values that differ from the medium profile are saved as explicit keys, the profile becomes custom, and every key you set by hand stays.
  4. Writes grim.json when the block changed: --auto, --profile, or the migration of old flat keys.
  5. Renders the three files into docker/: php-fpm-production.conf (the [www] pool), php-production.ini (memory_limit and the opcache) and nginx-production.conf (buffers, client_max_body_size, the optional FastCGI cache). Each says it was generated and not to edit it by hand; grim upgrade renders them too.
  6. Prints a summary of the effective values and the file names.

The block

"performance": {
  "profile": "medium",
  "fpm":       { "pm": "dynamic", "max_children": 20, "start_servers": 5, "min_spare": 3, "max_spare": 10, "max_requests": 500, "request_terminate_timeout": "60s" },
  "php":       { "memory_limit": "512M", "opcache": { "memory": 256, "interned_strings": 32, "max_files": 65407, "jit_buffer": "128M" } },
  "nginx":     { "fastcgi_cache": { "enabled": false, "ttl": "5m" }, "fastcgi_buffers": "8 16k", "client_max_body_size": "100M" },
  "services":  { "redis_maxmemory": "256mb", "horizon_workers": 10 },
  "resources": { "app": { "cpus": 2, "memory": "1G" }, "horizon": { "memory": "1536M" }, "scheduler": { "memory": "512M" }, "reverb": { "memory": "512M" } }
}

Those are the medium values, which is what every stack ran with before this block existed. small (1 CPU, 2 GB) halves the pool and the memory; large (4 CPUs, 8 GB) doubles them and gives nginx more buffers. Write only what you change:

Key Reaches How
fpm.* docker/php-fpm-production.conf Mounted into the app container as zzz-custom.conf, and baked into the image at grim build.
php.* docker/php-production.ini Mounted as conf.d/zz-grim.ini, and baked into the image.
nginx.* docker/nginx-production.conf Mounted into the nginx container; grim deploy reloads nginx.
services.redis_maxmemory docker-compose.yml redis-server --maxmemory.
services.horizon_workers docker-compose.yml HORIZON_MAX_PROCESSES in the horizon container's environment; takes effect when the app's config/horizon.php reads it.
resources.<service>.* docker-compose.yml deploy.resources.limits of app, horizon, scheduler, reverb.
php.opcache.preload docker/preload.php, docker/php-production.ini false in every profile. When you set it to true, grim writes the script that compiles the framework and the project's modules into opcache as PHP-FPM starts, and the ini points opcache.preload at it. Measured on the portal it is worth a few percent of throughput: opcache already keeps everything compiled after the first request, preload only spares that first one.
extra.fpm, extra.php, extra.nginx The file each names Your own directives, see below. Defaults docker/extra/fpm.conf, docker/extra/php.ini, docker/extra/nginx.conf.

The effective values travel with the heartbeat, so the portal shows what each project is sized with, and which extra files it carries.

Directives the block does not know

A directive the block has no key for goes into a file of your own. Create it at the default place, or name the place under extra, and the next grim deploy ships it: the rendered pool gets include= for extra.fpm, the rendered server block include /etc/nginx/extra/*.conf; for extra.nginx, and extra.php is mounted into PHP's conf.d after grim's own ini so it wins. Only a file that exists is included and mounted; a missing one changes nothing. grim never reads, validates or rewrites these files — grim upgrade leaves them alone — and the heartbeat only tells the portal that they exist.

; docker/extra/fpm.conf — pool directives, no [www] header
pm.status_path = /status
# docker/extra/nginx.conf — inside the server block
add_header X-Robots-Tag "noindex" always;

A directive that breaks the config breaks the container: nginx does not start, or FPM does not, the app never turns healthy and the deploy rolls back to the previous image — with the broken file still on the server, so fix it and deploy again.

Pitfalls

The FastCGI cache stores nothing by itself

nginx.fastcgi_cache.enabled: true adds a cache zone and the rules, but a response that sets a cookie or says Cache-Control: private is never stored, and a request that carries a session cookie or an Authorization header never reads the cache. That is every Laravel page by default. Only routes that answer Cache-Control: public, max-age=… without a session are served from it. The X-Cache-Status header says HIT, MISS or BYPASS.

A stack from before the block runs the old image defaults

An image built with an older grim has its opcache and memory_limit hardcoded, and a server registered then has no sizing files. The next grim deploy uploads the three files and the compose with the limits; the mounted files win over what the image bakes, so no rebuild is needed for fpm.* and php.*.

Preload can stop PHP-FPM

opcache.preload runs docker/preload.php when FPM starts. Every file is compiled on its own and one that does not compile is skipped, but a fatal error while compiling — a file that declares a class twice, a syntax error in a module — stops FPM before it serves a request. The app container never turns healthy and grim deploy rolls back. Remove php.opcache.preload from the block, then grim tune and deploy again, turns it off; no profile turns it on by itself.

Available memory is a snapshot

--auto uses what was free at the moment you ran it, with the app's own workers counted as used. Measure at a quiet hour, and on a server shared by several projects expect a lower number than the machine's size suggests.

After it finishes

Commit grim.json. Deploy, and check that PHP-FPM took the pool:

$ grim deploy
$ grim remote:exec docker compose exec -T app php-fpm -tt 2>&1 | grep max_children

The idea underneath