Skip to content

Send an image to a server

$ grim deploy production --tag=v1.4.2

The image is built and sits in GHCR under a tag. Nothing on the server has changed yet. grim deploy is the step that changes it: it tells one environment of the project to pull that tag and restart on it.

Reach for it on its own when build and rollout are separate decisions. The tag that passed on staging yesterday goes to production today, unchanged. Or the last release turned out badly and you want the previous one back, which is the same command with --rollback. When you build and ship in one breath, grim release does both.

Usage

$ grim deploy --tag=v1.4.2                   # production, asks before it starts
$ grim deploy staging --tag=v1.4.2 --force   # staging, no question
$ grim deploy production --rollback          # back to the tag before the current one

Run it anywhere inside the project. The project must have a home on the server already, made by grim server:add-project, and your SSH key must be accepted for the deploy user there. --tag is required; deploy never guesses one.

Arguments and options

Deploy project to server

Usage

grim deploy [-t|--tag TAG] [--rollback] [--force] [--skip-monitoring] [--] [<environment>]

Arguments

Argument Description
environment Target environment (production/staging) (optional, default: production)

Options

Option Description
-t, --tag=TAG Image tag to deploy
--rollback Rollback to previous version
--force Skip confirmation prompt
--skip-monitoring Do not register the tenant with the Monitoring HQ after the deploy

What it actually does

It asks Deploy shop@v1.4.2 to 203.0.113.10 (production)? first, unless --force. Then six phases, each printed with its result.

  1. Syncs the config. docker-compose.yml is rendered again from grim.json and uploaded to the project's directory on the server, together with the three sizing files docker/php-fpm-production.conf, docker/php-production.ini and docker/nginx-production.conf, rendered from the performance block (see grim tune). The image name, the domain, the extra domains registered for the project, the Horizon and Reverb services and the container limits all come from this render. So does the tag: it is written into the file as the default of every image line (${IMAGE_TAG:-v1.0.3}), and an IMAGE_TAG line an older grim left in the .env on the server is removed, so a docker compose up run by hand on the server stays on the deployed tag. Nothing in .env says which tag runs; the compose file does. After the stack is up, nginx is reloaded so a changed config takes effect without a recreate. Inside the stack nginx reaches PHP-FPM over a unix socket in a volume the two containers share (php-socket, mounted at /run/php), not over TCP; a stack registered before that gets the volume and the new configs with its next deploy, and docker compose up -d recreates both containers for it.
  2. Pulls the image for the tag.
  3. Restarts the containers. docker compose down, then up -d. The volume holding public/ is removed in between, so the new image's assets replace the old ones. Uploaded files live in another volume and stay.
  4. Waits for the app container to report healthy, for up to a minute and a half.
  5. Runs the deploy hooks. The commands under deploy.post-deploy in grim.json, in order; a project made by grim has php artisan migrate --force there. A hook that fails is named and fails the deploy. Then the app is verified with php artisan about, retried until health-timeout runs out. On success the tag is appended to .deploy-history on the server.
  6. Cleans up. Older images of the project are removed from the server and dangling ones pruned.

Between 5 and 6 comes the summary: tag, URL, duration, and the server's memory, load and disk.

Afterwards two things reach the portal, neither of which can fail the deploy that has already happened.

The tenant registers with the Monitoring HQ, once per environment. The first deploy asks the portal for the registration secret with your GitHub token (GET /api/registry/monitoring-secret, answered only to a wizard with package access), runs php artisan monitoring:register in the app container with it, and writes the moment into grim.json as deploy.environments.<environment>.monitoring.registered_at. Every later deploy sees that key and skips the round trip. The secret is the portal's and stays there: not in grim.json, not in the tenant's .env, not on your terminal. From then on the tenant reports to the HQ every five minutes (MONITORING_MODE=active in the .env written by grim server:add-project; a stack from before that gets the two lines with grim env:set). --skip-monitoring leaves all of this out; a portal that is not an HQ, a missing token or a failed monitoring:register is one line of explanation and nothing more. Commit the changed grim.json.

The deploy sends a heartbeat telling the portal what is now running — project, environment, modules, active themes, core version, PHP, host and URL. A heartbeat that does not arrive is printed and changes nothing.

Where host, path and domain come from

Every setting is looked up for the environment first, then for the project as a whole. Path and domain are the exception: they name one stack, so the top-level deploy.path and deploy.domain belong to production and no other environment inherits them.

Setting grim.json Then Last resort
Host deploy.environments.<env>.host, then deploy.host The registration in ~/.grim/servers.json None: No deploy host configured.
Path deploy.environments.<env>.path; for production also deploy.path The registration /opt/<project>, for staging /opt/<project>-staging
Domain deploy.environments.<env>.domain; for production also deploy.domain The registration None, and the deploy stops

There is no default domain. A project without one is refused before anything is uploaded: shop has no domain, and there is no default one. Set deploy.domain, or deploy.environments.<env>.domain, in grim.json, or register the project with grim server:add-project shop --domain=shop.example.com. Give each environment its own domain. The domain becomes the routing rule of that stack, and two stacks must not claim one hostname.

Pitfalls

The automatic rollback covers one failure only

When the verification in phase 5 fails, the server is put back on the last tag in .deploy-history and the command exits with an error. When the app container never turns healthy in phase 4, the deploy stops there, prints the last lines of the app log, and rolls nothing back. The new containers are left as they are. Read the log, then run grim deploy --rollback yourself or deploy a fixed tag.

A failed hook stops the deploy where it stands

The new image is running and healthy by the time the hooks run, so a hook that fails rolls nothing back — rolling a serving image back over a migration that did not apply trades a mistake for an outage. The deploy prints which hooks failed, leaves with an error, and the summary box is not shown. Fix it forward — grim remote:artisan <command> production runs a single command against the deployed stack — and the next deploy is an ordinary one.

A migration that says the table is already there

Base table or view already exists from php artisan migrate --force on an instance that skipped a few trains: a migration file renamed between trains carries the same table under a new name, and the new name has no row in migrations. grim migrate:mark-applied writes those rows without running anything; then deploy again.

$ grim migrate:mark-applied --all-existing --dry-run --remote=production   # what would be marked
$ grim migrate:mark-applied --all-existing --remote=production             # mark it

Two rollbacks bring you back

--rollback takes the tag before the current one from .deploy-history, rewrites the default in docker-compose.yml to it and records itself in the history as a new entry. Run it a second time and the tag "before the current one" is the release you were escaping from. To go further back than one step, deploy the tag you want by name. A rollback asks no question and runs no hooks, so migrations are not reversed. It also keeps the volume that holds public/, which a normal deploy replaces. The older image then serves the compiled assets of the newer release. When the fault is in the front end, deploy the older tag by name instead.

The image is not there

Image not found. Build it first: means the server could not pull that tag. Either it was never pushed, or it was pushed under another namespace than the one in the rendered compose file. grim build explains how the name is chosen. unauthorized in the pull output is a different matter: the server has no GHCR credentials that can read the namespace, and grim server:ghcr-login is the fix.

A large image on a slow line

The remote run is cut off after deploy.pull-timeout plus deploy.health-timeout plus a minute of slack: 900 and 60 seconds by default. Raise deploy.pull-timeout in grim.json when the first pull to a new server needs more.

The first deploy of a project creates an admin account, admin@<domain>, with a random password. The password is printed once, in the summary of that deploy, and kept nowhere in plain text. Copy it before the terminal scrolls away.

After it finishes

✓ Deployed shop@v1.4.2 to 203.0.113.10 is the last line. Open the URL from the summary. If the app came up but behaves wrongly, the way back is one command and does not need the old tag's name.

$ grim deploy production --rollback

The PHP-FPM pool the app runs with is not part of the image. It is a file on the server, written by grim tune.

Recipes that use it