> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sesameterminal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Advanced

# Advanced

> **In short.** This page holds what you need only now and then: how the deploy is
> protected, what is where on the server, a private repository, updates to the server
> scripts, rare restores, login limits, and the security checklist. Do the security checklist
> before you [go live](4-go-live.md).

## How the deploy is protected

The design keeps the Session Key away from the computer you develop on, where coding agents
run with your rights.

* **Signed tags only.** Each `task deploy...` command checks a release tag's signature
  against `SESAME_ALLOWED_SIGNERS`. `task deploy -- <tag>` checks the tag it installs. The
  other commands check the newest `release-*` tag on your computer. The command then runs
  `deploy/deploy.sh` from that tag, not the copy in your working tree.
* **The server checks again.** The server checks the signature against
  `/etc/sesame/allowed_signers`. It also checks that the signed tag has the name you asked
  for.
* **One command for each touch.** `deploy.sh` makes one SSH connection for each command. It
  uses no SSH agent and no forwarding, and the server's host key must match the one you
  recorded.
* **No shell for the deploy key.** The server sends every `deploy` connection to
  `sesame-deploy-shell`. It accepts only `deploy <tag>`, `rollback [tag]`, `restore <name.db>`,
  `restore-stdin <name.db>`, `logs sesame|caddy`, `backup-now`, `list-backups`, `start` and
  `status`. It records each request in the journal with the tag `sesame-deploy-shell`. Then it
  runs `sudo sesamectl <command> [argument]`.
* **The deploy account is not root.** Its one sudoers rule allows `sesamectl` and nothing
  else. It is not in the `docker` group, because access to Docker is root access. Do not list
  `docker compose` commands in sudoers in place of `sesamectl`: `-f`, `run --entrypoint` and
  `exec` run any command as root.
* **The server builds.** `sesamectl` builds both images from `git archive` of the tag, so
  nothing from your computer gets to the server. It refuses to run while anyone other than
  root can write to `/etc/sesame`, a file in it, or `/opt/sesame`. A person who can write
  `deploy.conf` or `allowed_signers` chooses the code or the signer.
* **Secrets stay on the server.** `sesame.env` is root:root 0600. The deploy account cannot
  read it, and no command prints it.
* **Backups leave encrypted.** The server encrypts each backup to your `age` public key. The
  backup account can read only the encrypted copies. Neither machine has the key that
  decrypts them.
* **Computers with agents.** Sign release tags only on a computer without agents. The person
  who has the signing key decides what the server runs as root. If you must deploy from a
  computer where agents run, use a security key with a PIN (`verify-required`). A key file
  with a passphrase is not enough there. A process with your rights can read the file. It can
  also read the passphrase as you type it. On such a computer, the local tag check stops only an edited
  checkout. The protection comes from the server's forced command and its own signature
  check.
* **What agents may do.** Agents may change `Dockerfile`, `deploy/` and `Taskfile.yml` through
  a reviewed pull request. They never run deploys or open SSH to the server. They never use or
  read a key or `sesame.env`, and never sign or push release tags. They never edit
  `/etc/sesame/*`, `SESAME_ALLOWED_SIGNERS`, `deploy/.deploy.env`, git's signing settings,
  sudoers, sshd or the firewall, and never add a proxy.
* **Lost deploy key.** In the provider's web console, delete
  `/etc/ssh/authorized_keys/deploy`. If someone possibly used the key on the server, follow
  [Session Key revocation](../../runbooks/session-key-setup.md#6-revocation) and the
  [incident runbook](../../runbooks/incident.md).

## Files on the server

| Path | What it is |
| - | - |
| `/opt/sesame/sesame.env` | The app's settings and secrets, root:root 0600 |
| `/opt/sesame/.env` | Written by `sesamectl`: `DOMAIN`, `ACME_EMAIL`, `SESAME_IMAGE_TAG`, `SESAME_PREVIOUS_IMAGE_TAG`. No secrets |
| `/opt/sesame/compose.yaml`, `/opt/sesame/Caddyfile` | Copied from the release tag at each deploy and rollback |
| `/opt/sesame/repo.git` | The release tags the server fetched. A tag fetched once never moves |
| `/etc/sesame/deploy.conf`, `/etc/sesame/allowed_signers`, `/etc/sesame/backup-recipients.txt` | The server's settings, owned by root |
| `/usr/local/sbin/sesamectl`, `/usr/local/sbin/sesame-deploy-shell` | The server scripts |
| `/etc/systemd/system/sesame-seal-backups.service`, `/etc/systemd/system/sesame-seal-backups.timer` | The hourly backup encryption |
| `/etc/sudoers.d/sesame`, `/etc/ssh/sshd_config.d/50-sesame.conf`, `/etc/ssh/authorized_keys/` | Access rules |
| The `sesame_data` volume, `/data` in the container | The database, and the backups in `backups/` |
| `/var/lib/sesame-backup/outbox` | Encrypted backups that wait for the backup machine |

The app runs in two containers:

| Container | What it does | Memory | Processes |
| - | - | - | - |
| `caddy` | HTTPS with certificates from Let's Encrypt, HTTP/3, compression, a 128 KB limit on request bodies. The only container with open ports: 80, 443 and 443/udp | 256 MB | 128 |
| `sesame` | The app, on an internal network only | 768 MB | 256 |

Both containers run as user 65532, with a read-only file system and no capabilities. Each
keeps at most five log files of 10 MB. `task deploy:logs` shows the last 300 lines. For more,
run `cd /opt/sesame && sudo docker compose logs sesame` on the server. When the app stops, it
first finishes the requests that run. Docker gives it 30 s.

```text theme={null}
browser --HTTPS--> caddy :80 :443 --> sesame :8080 --> Polymarket, Telegram
your computer --SSH, one command--> deploy --> sesame-deploy-shell --> sudo sesamectl
backup machine --rsync, read only--> sesame-backup --> encrypted outbox
```

## Private repository

If the repository is private, the server needs a key to read it.

1. In GitHub, open the repository settings, then **Deploy keys**. Add a key without write
   access.
2. On the server, put the private key in `/root/.ssh`.
3. Add `github.com` to `/root/.ssh/known_hosts`. Use the fingerprints that GitHub publishes.
4. In `/etc/sesame/deploy.conf`, write `REPO_URL` in the form
   `git@github.com:<owner>/<repo>.git`.

## Updating the server scripts

A deploy never replaces `sesamectl`, `sesame-deploy-shell` or the systemd files. When a
release changes `deploy/sesamectl`, the deploy prints:

```text theme={null}
sesamectl: deploy/sesamectl in <tag> differs from /usr/local/sbin/sesamectl; review it and reinstall as root ...
```

The deploy prints no message for `sesame-deploy-shell` or `deploy/systemd/`. Read the release
notes to know if they changed.

Install the new `sesamectl` from the tag that the server already fetched. On **the server**:

1. Copy the script out of the tag:

   ```sh theme={null}
   sudo sh -c 'git -C /opt/sesame/repo.git show refs/tags/<tag>:deploy/sesamectl > /tmp/sesamectl'
   ```

2. Read the script:

   ```sh theme={null}
   less /tmp/sesamectl
   ```

3. Install it and remove the copy:

   ```sh theme={null}
   sudo install -o root -g root -m 0755 /tmp/sesamectl /usr/local/sbin/sesamectl && sudo rm /tmp/sesamectl
   ```

For `sesame-deploy-shell`, do the same steps with `deploy/sesame-deploy-shell` and
`/usr/local/sbin/sesame-deploy-shell`. For a file in `deploy/systemd/`, install it with mode
`0644` in `/etc/systemd/system/`, then run `sudo systemctl daemon-reload`.

## Backup details

* Every `BACKUP_INTERVAL` (default 6 h), the app writes `sesame-<UTC time>.db` to
  `/data/backups`. It keeps the newest `BACKUP_KEEP` backups (default 28).
* Beside each backup, the app writes `<name>.mac`. This is a checksum keyed from
  `SESSION_SECRET`. It proves that this app wrote the backup.
* A backup holds all the app's data: the order audit trail, settings, alerts and login
  sessions.
* Every hour, `sesamectl seal-backups` encrypts each new backup and its `.mac` into
  `/var/lib/sesame-backup/outbox/<name>.age`. When the app deletes a backup, the encrypted
  copy goes too.
* The encryption reads only regular files, as the app's user. It skips files over 512 MiB.
* The outbox holds at most 128 files. When it is full, the encryption stops with an error
  ([troubleshooting](troubleshooting.md#deploy-commands-fail)).
* A restore checks the MAC, the database's integrity and its schema. It also checks that the
  backup's audit trail is the start of the current audit trail.
* A restore deletes all login sessions and logs out every remembered browser.

## Restore an off-site copy

Use this when the backups on the server are lost, or to restore an older copy. Do it on a
trusted machine that has the backup key `sesame-backup.agekey`.

1. In the folder with the encrypted copies, check them against `SHA256SUMS`:

   ```sh theme={null}
   sha256sum -c --ignore-missing SHA256SUMS
   ```

   Each file shows `OK`. Do not use a file that shows `FAILED`.

2. Decrypt the backup:

   ```sh theme={null}
   age -d -i sesame-backup.agekey -o sesame-20261001T040000Z.db sesame-20261001T040000Z.db.age
   ```

3. Decrypt its `.mac` file:

   ```sh theme={null}
   age -d -i sesame-backup.agekey -o sesame-20261001T040000Z.db.mac sesame-20261001T040000Z.db.mac.age
   ```

4. From the repository folder, upload and restore it. The `./` tells the command to upload
   the file:

   ```sh theme={null}
   task deploy:restore -- ./sesame-20261001T040000Z.db
   ```

5. Delete the decrypted copies.

Keep the `.mac` file beside the `.db` file. The upload sends both. The server accepts at most
1 GiB and only these two names. It never writes over a file that exists. If the name is
already on the server, rename your file before the upload.

## Restore onto a new server

A normal restore compares the backup with the history of the current data. A new server has
no history, so the normal restore refuses the backup. For this case only, an administrator on
the server runs `restore-fresh`. It still needs the backup's `.mac` to match the old
`SESSION_SECRET`. It refuses when the server already has order history.

1. Set up the new server with pages [1](1-prepare-server.md) and [2](2-first-deploy.md). Put
   the **old** `SESSION_SECRET` in `sesame.env`.
2. Upload the decrypted backup and its `.mac` as in
   [Restore an off-site copy](#restore-an-off-site-copy). The server keeps the file but refuses
   the restore, because there is no history to compare. The app stays stopped.
3. On **the server**, restore the file:

   ```sh theme={null}
   sudo sesamectl restore-fresh <file name>
   ```

   It prints `MAC verified; no current history to continue (...)`. It also prints the number
   of rows and the head of the order audit trail. Compare them with the last values the old
   server reported. The Telegram message `Server started` shows the head.
4. Log in and check the data.

On a server that has history, a refused restore means that the backup does not match that
history. Find the cause. Do not use `restore-fresh` there.

### Backups that fail the MAC check

The MAC check fails for a backup written under a `SESSION_SECRET` that changed or was lost. It
also fails for a backup from a release older than the `.mac` files. `sesamectl` restores none
of these. Only an administrator at the server console can install one.

1. Check the encrypted copy against `SHA256SUMS` before you decrypt it, as in
   [Restore an off-site copy](#restore-an-off-site-copy). Use the backup only if the check
   shows `OK`.

2. Upload it with `task deploy:restore`. The server keeps the file and refuses the restore.

3. On **the server**, go to the app's folder:

   ```sh theme={null}
   cd /opt/sesame
   ```

4. Stop the app:

   ```sh theme={null}
   sudo docker compose stop sesame
   ```

5. Install the backup without the checks:

   ```sh theme={null}
   sudo docker compose run --rm --no-deps -T sesame restore --from <file name> --accept-unverified-backup
   ```

6. Start the app:

   ```sh theme={null}
   sudo sesamectl start
   ```

## Login limits

* Each IP address gets 5 wrong passwords. An IPv6 address counts as its /64 network.
* After that, each wrong password locks that address out. The lockout starts at 1 minute and
  doubles each time, up to 15 minutes. After 1 hour with no attempt, the count starts again.
* A second limit covers all browsers that did not log in before, together: 20 login
  attempts a minute. A person with four or more new IPv6 networks a minute can keep new
  browsers out while this lasts.
* A browser that logged in before is remembered for 180 days. These limits do not stop it.
* Each time the second limit stops a browser, you get the notification
  `Logins refused for new devices`. You get at most one each minute.
* **Log out everywhere** and each restore make all browsers new again.

## Security checklist

Do these checks on the real server after the first deploy. For each check, record the date,
the release tag and the output. S1 to S9 must pass before you add the Session Key (S10).

| # | Check | Passes when |
| - | - | - |
| S1 | Test `https://<your-domain>` with SSL Labs or `testssl.sh` | Grade A or better. TLS 1.0 and 1.1 are refused. The certificate is from Let's Encrypt. No other name is served |
| S2 | From outside the provider's network, run `nmap -Pn -p- <server-ip>` and `nmap -sU -p 443 <server-ip>`. With an `AAAA` record, also run `nmap -6 -Pn -p- <ipv6>` | Only 22 (from your addresses), 80, 443/tcp and 443/udp answer. Nothing answers on 8080 or 2019 |
| S3 | Run `curl -sI` on `https://<your-domain>/`, `/api/health`, `/nope.js` and `/api/nope` | Each has exactly one `strict-transport-security` header, the full CSP and the other security headers. `http://` moves to `https://` |
| S4 | Log in, then look at the cookie in the browser | `__Host-sesame_session`, with `Secure`, `HttpOnly` and `SameSite=Strict`, and no `Domain` |
| S5 | Do six wrong logins from one client, with a false `X-Forwarded-For: 1.2.3.4` header | The `ip` in the app's log is your real address on each line. The sixth login gets 429 |
| S6 | With an `AAAA` record: do one wrong login over IPv6 (below) | Caddy's `remote_ip` and the app's `ip` show your IPv6 address. They do not show `172.29.53.1` or an `fd53:5e5a:6d65::` address |
| S7 | Do the [restore test](3-backups.md#step-4-test-a-restore), then check the audit trail (below) | The restore prints `MAC verified; audit chain continues into the current database's` and `known-device cookies revoked`. The alert is gone. The audit check prints `order_audit chain OK`. Each of these is refused: a copy with one byte changed in the audit trail, and the backup without its `.mac`. When the current data has order history, a backup with an empty audit trail is also refused |
| S8 | Have an open order of the minimum size and **Cancel on shutdown** on. Place a second order, then at once run `cd /opt/sesame && sudo docker compose stop sesame` | The log shows the drain, the wait for the order, the cancel and the stop, all within 30 s. After `task deploy:start`, no order is unresolved |
| S9 | The server checks below | All results are as noted |
| S10 | Before the Session Key goes into `sesame.env` | S1 to S9 are recorded. Tags are signed only on a computer without agents. `SESAME_ALLOWED_SIGNERS` is outside the repository. The deploy key is a security key with `verify-required` and `from=`, or it has a passphrase and you use it only from a computer without agents |
| S11 | Optional: fill the login limit from four or more addresses, then log in from a remembered browser | One `Logins refused for new devices` notification. The remembered browser logs in |

S6, from a computer with IPv6:

```sh theme={null}
curl -6 -s -o /dev/null -w '%{http_code}\n' -H 'Origin: https://<your-domain>' \
  -H 'Content-Type: application/json' --data '{"password":"wrong"}' https://<your-domain>/api/auth/login
task deploy:logs -- caddy
task deploy:logs -- sesame
```

If the addresses are wrong, make sure that Docker Engine is 27 or later. Make sure that
`/etc/docker/daemon.json` does not set `"ip6tables": false`. Then deploy again. Until S6
passes, remove the `AAAA` record.

S7, the audit check on the server:

```sh theme={null}
cd /opt/sesame && sudo docker compose exec -T sesame /sesame audit-verify /data
```

S9, on the server. The comment after each command gives the expected result:

```sh theme={null}
sudo stat -c '%U:%G %a' /opt/sesame/sesame.env                     # root:root 600
sudo stat -c '%U:%G %a %n' /etc/sesame /etc/sesame/* /opt/sesame    # root:root, no group or other write
ls -l /etc/ssh/authorized_keys                                      # root:root 0644 files
id deploy                                                           # no docker group
getent passwd deploy sesame-backup                                  # /bin/sh for both
sudo -l -U deploy                                                   # only /usr/local/sbin/sesamectl
sudo sshd -T -C user=deploy,host=x,addr=<your-ip> | grep forcecommand   # /usr/local/sbin/sesame-deploy-shell
sudo docker inspect sesame-sesame-1 --format '{{.Config.User}} {{.HostConfig.ReadonlyRootfs}} {{.HostConfig.CapDrop}} {{.HostConfig.SecurityOpt}}'
sudo docker inspect sesame-caddy-1 --format '{{.Config.User}} {{.HostConfig.ReadonlyRootfs}} {{.HostConfig.CapDrop}} {{.HostConfig.Memory}} {{.HostConfig.PidsLimit}}'
timedatectl | grep synchronized                                     # yes
```

Both `docker inspect` commands show `65532:65532`, `true` and `[ALL]`. The `sesame` container
also shows `[no-new-privileges:true]`. The `caddy` container also shows `268435456 128`.

From your computer, the server refuses each of these commands:

```sh theme={null}
ssh -i ~/.ssh/sesame_deploy -o IdentityAgent=none deploy@<your-domain> id
ssh -i ~/.ssh/sesame_deploy -o IdentityAgent=none deploy@<your-domain>
ssh -i ~/.ssh/sesame_deploy -o IdentityAgent=none deploy@<your-domain> restore-fresh x.db
```

On the server, `journalctl -t sesame-deploy-shell` lists the three refusals.

Last, do the proxy checks in
[step 1 of Prepare the server](1-prepare-server.md#step-1-check-that-polymarket-allows-the-servers-location)
again.

## Further reading

* [configuration.md](../configuration.md): every setting.
* [Session Key setup](../../runbooks/session-key-setup.md), [Telegram](../../runbooks/telegram.md),
  [live verification](../../runbooks/live-verification.md) and
  [incidents](../../runbooks/incident.md).
* The security reviews behind this setup: [threat model](../../security/threat-model.md),
  [phase 6 findings](../../security/findings-phase-6.md) and
  [phase 6 recheck](../../security/findings-phase-6-recheck.md).

Previous: [Troubleshooting](troubleshooting.md) · Back to the [overview](index.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.