First deploy to a fresh VPS
The server is prepared once, the project gets a home on it once, and from then on shipping is one command. Most of what goes wrong on the first day is DNS or a token, so both are settled before the server is touched.
Before you start
The project runs on your machine and its suite passes. The release in step six runs the tests in the local stack and stops on the first failure.
The server is a fresh Ubuntu or Debian machine that accepts your SSH key. The key has to be ~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub, because that is the one the server's deploy user will be given.
The domain already points at the server. The certificate is issued the moment the project is added, and Let's Encrypt has to find the server behind the name.
$ ssh root@203.0.113.10 true
$ dig +short A shop.example.com
203.0.113.10Create a classic GitHub token with the read:packages scope and nothing else. It is for the server, which only ever pulls images. A fine-grained token is accepted by GitHub and still cannot pull.
The ritual, in order
Seven steps. The first three are done once per server, the fourth and fifth once per project, and the sixth is the one you will repeat.
Let your own login push images
Your machine builds the image and pushes it to GHCR, and that needs a scope the ordinary login does not ask for.
grim auth:loginadds it.$ grim auth:login --packages-writeImages go to
ghcr.io/grimoiry/grim-<project>unlessgrim.jsonsays otherwise. When you cannot write there, name your own GitHub user or organisation and commit the change, so that build and deploy agree on the name whoever runs them."docker": { "owner": "acme" }Raise the server
grim server:setupinstalls Docker, MySQL, nginx, certbot and Traefik, creates thedeployuser with your key, and remembers the server under the alias. Every later command takes that alias as--host. The email is the one Let's Encrypt will write to.$ grim server:setup 203.0.113.10 --alias=vps1 --email=ssl@example.comA cloud image that refuses
rootneeds--user=ubuntu, or whatever login the provider gave you. Setup configures no firewall. Keep port 3306 closed at the provider: MySQL listens on every interface so that the containers can reach it.Give the server its token
The server has to pull a private image.
grim server:ghcr-loginasks for the token from Before you start at a hidden prompt, logs thedeployuser in and prints Docker's own answer.$ grim server:ghcr-login --host=vps1 ✓ Login SucceededSetup takes the same token as
--ghcr-token, but that step shows a tick whether the login worked or not, and the token stays in your shell history. This one tells the truth.Give the project a home
Run
grim server:add-projectfrom the project directory. It readsgrim.jsonthere to learn the image name and whether the project runs Horizon or Reverb.$ cd ~/code/shop $ grim server:add-project shop --host=vps1 --domain=shop.example.comIt creates the database
shopand its user, the nginx vhost, the certificate,/opt/shopwith adocker-compose.yml, and a.envwith a newAPP_KEYand the database password. Use the namegrim.jsoncarries, or the release will not find what you registered.Read the
SSLrow of the summary before you go on.faileddoes not stop the run, and it nearly always means the DNS record is not there yet. The certbot command to repeat is printed under the table.Keep the door shut until it is ready
Optional.
grim server:authputs a password prompt in front of the whole site except/api/v1/healthcheck. The generated password is printed once.$ grim server:auth shop --enableRelease
grim releasepicks the tag, runs the suite, builds the image forlinux/amd64, pushes it and deploys it. With no image in GHCR yet the tag isv1.0.0.$ grim up $ grim release Releasing shop@v1.0.0 … Deploy shop@v1.0.0 to 203.0.113.10 (production)?The question comes after the build, several minutes in. On an Apple Silicon machine the build runs under emulation and takes longer still. Once you answer, the server pulls the image, starts the containers, waits for the app to report healthy and runs
php artisan migrate --force.The first deploy creates the account
admin@shop.example.comwith a random password. It is shown once, in theAdminandPasswordrows of that deploy's summary, and stored nowhere in plain text. Copy it before the terminal scrolls away.Look at it
✓ Deployed shop@v1.0.0 to 203.0.113.10is the last line. Ask the site itself, then look at what the app logged while it started, withgrim logs.$ curl -I https://shop.example.com/api/v1/healthcheck $ grim logs app --remote=productionWhen the door was shut in step five, open it on the day the site goes public.
$ grim server:auth shop --disable
What you have now
On the server, one directory per project and one Traefik for all of them. nginx on the host holds ports 80 and 443 and the certificates, and passes each domain to Traefik, which finds the project's containers by hostname. MySQL runs on the host.
/opt/traefik/ shared, one per server
/opt/shop/
docker-compose.yml rendered from grim.json at every deploy
.env APP_KEY and the database password live only here
.deploy-history one line per deployed tagOn your machine, ~/.grim/servers.json knows the server and the project. A colleague's machine does not. Write the target into grim.json and commit it, and anyone whose key the deploy user accepts can release:
"deploy": { "host": "203.0.113.10", "domain": "shop.example.com" }These two keys describe production only while production is the only environment. Before you add a second one, move them under deploy.environments, as Staging to production, one image does. A top-level deploy.domain is never handed to staging, so without the move staging depends on the registration on your machine alone.
The next project on the same server starts at step four. The next release of this one is step six alone, and grim deploy production --rollback takes the last one back.
When it does not work
“Cannot connect to root@203.0.113.10”
On AWS, GCP and Azure images that is not a firewall. They log in as ubuntu, ec2-user or admin. Pass the login as --user. It needs passwordless sudo.
The site answers without a certificate
The SSL row said failed and the run went on. Fix the DNS record, wait until dig shows the server's address, then run the certbot command that was printed under the summary. skipped (no acme_email recorded for this server) means setup ran without --email. Run it again with one.
“denied: permission_denied: create_package”
The push was refused because the namespace is not yours. Set docker.owner in grim.json as in step one and release again.
The server cannot pull the image
unauthorized in the pull output means the server's token cannot read the namespace. Repeat step three with a classic token whose owner can read the packages. Image not found. Build it first: means the tag was never pushed, or was pushed under another name than the one in grim.json now.
The app container never turns healthy
The deploy stops, prints the last lines of the app log and rolls nothing back. Nothing was live before, so there is nothing to return to. The cause is in those lines, and it is usually a value missing from .env. Set it with grim env:set, then send the same tag again with grim deploy --tag=v1.0.0.
Next recipe
Staging to production, one image
Register a staging environment next to production, name both in grim.json, release to staging, look at it, then send the same tag to production without building again.
Read itSpells used here
grim server:setup
Raise a production server
Turn a bare Ubuntu or Debian VPS into a host for deployed projects, with Docker, MySQL, nginx, Traefik and a deploy user.
grim server:ghcr-login
Let a server pull images
Log a server's deploy user in to the GitHub Container Registry, so the server can pull the project's private images.
grim server:add-project
Give a project a home
Prepare one environment of a project on a production server, with its database, nginx vhost, certificate, compose stack and .env.
grim server:auth
Put a password on a site
Switch HTTP Basic Auth on or off for a deployed project, in the host nginx that stands in front of it.
grim release
Ship in one pass
Test, build, push and deploy the project to an environment with a single command and a single tag.
grim deploy
Send an image to a server
Roll a pushed image tag out to one environment over SSH, verify the app answers, and fall back to the previous tag when it does not.