Skip to content

Where the binaries go

A SynQt deployment is a whole project directory.

Every entity finds its runtime files relative to the directory it starts from, exactly as synqt.yaml spells them: its topology under build/<entity>/, its certificate under synqt/mesh/, its secrets in its own .env, its schema and data in its own folder, and, for the edge, the client bundle under build/client/. Copy build/edge/edge somewhere alone and it starts, looks for all of that, and finds none of it.

Step 1: The shape on each host

Take the artifact your pipeline produced and keep only what each host runs.

The edge host:

/srv/gavel/
  synqt.yaml
  synqt.production.yaml
  certs/edge/fullchain.pem
  certs/edge/privkey.pem
  synqt/mesh/
    ca.crt
    edge.crt
    edge.key
  build/
    edge/             # the edge binary and its topology.json
    client/           # the bundle it serves
  generated/web/edge/ # the QML the edge loads at startup
  web/edge/
    .env              # the OAuth client secret

The books host:

/srv/gavel/
  synqt.yaml
  synqt.production.yaml
  synqt/mesh/
    ca.crt
    books.crt
    books.key
  build/
    books/            # the binary and its topology.json
  generated/db/relational/books/   # the QML it loads at startup
  db/relational/books/
    .env
    schema.sql
/srv/gavel-data/books/
  app.db              # the database, outside the project directory

Create the data directory once, owned by the user the entity runs as, since that user cannot create anything under /srv:

sudo install -d -o gavel -g gavel /srv/gavel-data/books

Three points about these trees:

  • Entity source directories travel, but only for run time files. On a host, db/relational/books/ holds .env and schema.sql. The QML the entity runs is the copy synqt build wrote under generated/, which travels beside it.
  • synqt.yaml travels, because the entities use the paths it spells. So does the profile, because each entity resolves the same layers the build did.
  • The data lives outside the project directory. A relational entity opens settings.file, by default data/app.db in its own directory. The production profile moves it to /srv/gavel-data/books/, so neither synqt clean nor a new release can take it, and your backup job points there.

Step 2: Qt has to be there

synqt build runs no deployment step for service binaries, so a service host needs the pinned Qt kit, at the path the build used or inside the image. There are two right ways and one wrong way:

  • A container image built from the same base as your build machine. The least surprising option, and the right one if you plan to use an orchestrator.
  • The same toolchain directory on the host. Copy synqt/toolchain/ with the rest, or run synqt build on the host once to fill it. Heavier, but needs no container runtime.
  • Never a distribution Qt of a nearby version. The binaries were compiled against one Qt and load whatever the linker finds. A near miss fails in worse ways than a missing library.

The desktop client is the exception: it carries its own Qt, which the platform step in Cutting a release bundles.

Step 3: Read the start plan

Every build writes build/process-manifest.json, the input your process manager needs:

{
  "start_order": ["books", "edge"],
  "processes": [
    {
      "entity": "books",
      "binary": "build/books/books",
      "bind": "loopback",
      "mesh_cert": "synqt/mesh/books.crt",
      "mesh_key": "synqt/mesh/books.key",
      "ca_cert": "synqt/mesh/ca.crt"
    },
    {
      "entity": "edge",
      "binary": "build/edge/edge",
      "bind": "public",
      "mesh_cert": "synqt/mesh/edge.crt",
      "mesh_key": "synqt/mesh/edge.key",
      "ca_cert": "synqt/mesh/ca.crt"
    }
  ],
  "client_served_from": {"app": "build/client/"}
}

It answers a supervisor's three questions:

  • start_order lists owners before consumers. A consumer retries, so starting out of order is not fatal, but it turns a clean boot into a wait, and a first deploy into a debugging session about whether the link works.
  • bind names the one entity that faces the public interface. If you ever find a second, the topology is wrong, not the host.
  • Each entry names the files that entity expects. Check that list before deciding a start failure is a code problem; it usually is not.

client_served_from names the directory the app bundle is in, which the edge serves.

Step 4: Start it by hand, once

Before writing any service unit, check that the tree is right:

cd /srv/gavel
synqt doctor --profile production

doctor reports the resolved toolchain, which entities have a certificate, any selected provider whose driver is missing, and your Qt license mode with its obligations. Fix whatever it names. Then:

synqt serve --profile production

synqt serve starts each entity from the project root in manifest order, then returns. Open the site, place a bid, close a lot, and check that the Hall of Fame remembers it.

synqt serve leaves supervision to you: an entity that dies stays down, hence the next step. It also passes --dev to nothing, which keeps the development sign-in and the plaintext localhost link out of a deployment. Use it to bring up a staging machine by hand and to check the tree works. Use a process manager for anything that must stay up.

Step 5: Keep it alive

One unit per entity. On the database host, /etc/systemd/system/gavel-books.service:

[Unit]
Description=gavel books entity
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=gavel
Group=gavel
# Every path the entity reads is relative to the project root, so this line decides
# the deployment shape.
WorkingDirectory=/srv/gavel
ExecStart=/srv/gavel/build/books/books
Restart=on-failure
RestartSec=2

# The entity reads db/relational/books/.env itself, so systemd does not need to know the secrets.
# What it can do is make sure nothing else on the box can read them.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/gavel-data/books

[Install]
WantedBy=multi-user.target

On the edge host, /etc/systemd/system/gavel-edge.service has the same shape with three differences:

[Unit]
Description=gavel web edge
After=network-online.target gavel-books.service
Wants=network-online.target

[Service]
Type=simple
User=gavel
Group=gavel
WorkingDirectory=/srv/gavel
ExecStart=/srv/gavel/build/edge/edge
Restart=on-failure
RestartSec=2

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
# Binding 443 without running as root.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target

After=gavel-books.service on the edge unit follows start_order. It orders the two only when both run on one host, and like the manifest it is advice: the edge would retry anyway. But a boot where the link comes up at once gives logs you can read.

sudo systemctl enable --now gavel-books
sudo systemctl enable --now gavel-edge

Step 6: Close the doors

The topology says the database is private. Make the network agree.

  • The public host exposes one port, the edge's. If you can, expose neither the mesh port nor SSH to the world.
  • The database host exposes its mesh port only to the edge host, through a security group, a firewall rule or a private subnet, whatever your hosting offers.
  • Mesh links use mutual TLS everywhere, so a database reachable by accident still refuses strangers. That is the second line of defense, after the network. See network segmentation and the database.

Try it, then think

Question

A colleague wants to run the edge from /usr/local/bin, like a normal daemon. They copy build/edge/edge there, write a unit with no WorkingDirectory, and start it. It fails. Before reading on: what does it fail to find first, and why is that the right failure?

Solution

It fails on its topology. The entity reads build/edge/topology.json at startup to learn what it owns, what it consumes and where its peers are, and it looks for the file relative to where it started. From /, that path does not exist.

Past that, it would fail on the certificate, then the bundle, then the env file: four failures in a row with one cause.

Fix it by setting the unit's WorkingDirectory to the project root, not by making the paths absolute, because the project root is the deployment. Everything an entity needs is described relative to it, in one readable file, so you can look at a host and see the whole system.

To have the binary on a path, symlink it. It still runs with the working directory the unit sets.

Advice worth taking now

  • Back up /srv/gavel-data/, not build/. A commit reproduces the build; nothing reproduces the data.
  • Use the same project root path on every host. /srv/gavel on both means one unit template and one runbook.
  • Log to the journal. The entities write to standard error, and systemd captures it. Do not add file logging without a reason; the reason usually turns out to be a missing metric, not a missing file.
  • Keep the previous release directory. Make the project root /srv/gavel-2026-08-03/, with /srv/gavel a symlink to it, so a rollback means moving the symlink and restarting. Cutting a release builds on this.
  • Run the security checklist before you call it done. It is short, and meant for deploy time.

Next: Cutting a release, and what changes on the second deploy.