Skip to content

Liber Tertius · Chapter 4 · Shipping

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.

≈40 min 7 steps A VPS you reach over SSH A domain pointed at it A classic GitHub token
Source

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.10

Create 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.

  1. 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:login adds it.

    $ grim auth:login --packages-write

    Images go to ghcr.io/grimoiry/grim-<project> unless grim.json says 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" }
  2. Raise the server

    grim server:setup installs Docker, MySQL, nginx, certbot and Traefik, creates the deploy user 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.com

    A cloud image that refuses root needs --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.

  3. Give the server its token

    The server has to pull a private image. grim server:ghcr-login asks for the token from Before you start at a hidden prompt, logs the deploy user in and prints Docker's own answer.

    $ grim server:ghcr-login --host=vps1
      ✓ Login Succeeded

    Setup 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.

  4. Give the project a home

    Run grim server:add-project from the project directory. It reads grim.json there 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.com

    It creates the database shop and its user, the nginx vhost, the certificate, /opt/shop with a docker-compose.yml, and a .env with a new APP_KEY and the database password. Use the name grim.json carries, or the release will not find what you registered.

    Read the SSL row of the summary before you go on. failed does 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.

  5. Keep the door shut until it is ready

    Optional. grim server:auth puts a password prompt in front of the whole site except /api/v1/healthcheck. The generated password is printed once.

    $ grim server:auth shop --enable
  6. Release

    grim release picks the tag, runs the suite, builds the image for linux/amd64, pushes it and deploys it. With no image in GHCR yet the tag is v1.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.com with a random password. It is shown once, in the Admin and Password rows of that deploy's summary, and stored nowhere in plain text. Copy it before the terminal scrolls away.

  7. Look at it

    ✓ Deployed shop@v1.0.0 to 203.0.113.10 is the last line. Ask the site itself, then look at what the app logged while it started, with grim logs.

    $ curl -I https://shop.example.com/api/v1/healthcheck
    $ grim logs app --remote=production

    When 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 tag

On 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.