Bouée
Get started

Automated install

Install and set up Bouée from a file, with no setup link and no browser, and script it.

Everything first-run setup asks for in the browser can be written in a file instead: staff, workspaces, their brands and support addresses, who may sign in, and apps that use the API. Bouée applies that file, bouee.yaml, every time it starts, so an installation can come up from cloud-init, a CI job or Ansible and be ready without anyone opening a setup link.

Three pieces make that work:

  • bouee.yaml, the installation described in a file (the file).
  • RESEND_API_KEY, so Bouée can reach Resend without the OAuth consent screen (Resend).
  • bin/bouee, which asks the running server what it still needs, lists the DNS records to publish, and applies the file again (commands).

Quick start

On a server with Docker, with DNS for Bouée's address already pointing at it (see Requirements):

cd bouee
export RESEND_API_KEY=re_...          # a Resend API key with full access
bin/install --domain support.example.com --config ~/bouee.yaml --yes
bin/bouee dns-records                 # the records to publish at your DNS provider
bin/bouee status --wait 1800          # exits 0 once mail goes out and comes in

bin/install --config does what it always does (Install), and also:

  • copies the file to config/bouee.yaml, which Compose mounts into the web container at /config, read-only;
  • sets BOUEE_CONFIG="/config/bouee.yaml" in .env;
  • writes RESEND_API_KEY into .env when it is set in the shell, and any other variable you name with --env NAME, such as an app's client secret. Neither is printed;
  • applies the file once Bouée answers and shows what it did, instead of printing a setup link.

When the file names staff, first-run setup is finished for them: there is no setup link, and they sign in at APP_URL/login with a code sent by email. Without staff in the file, setup stays open and the setup link is printed as usual; whoever completes setup then owns the workspaces the file made.

The same file sets up an installation on Fly.io: fly.toml writes it into every Machine, the secrets are Fly secrets rather than .env, and bin/bouee reaches the app through fly ssh console (Install on Fly.io).

What happens at each start

  1. Before Bouée answers anything, it applies what the database holds: staff, workspaces, brands, members, apps. That is why no setup link appears for an installation the file sets up.
  2. About fifteen seconds later, once the server is up, it sets up what Resend holds: it adds each support domain to your Resend team with receiving on, links it, and creates each workspace's incoming mail webhook.
  3. Whatever is still pending, usually DNS records Resend has not verified yet, is tried again every five minutes for a day. bin/bouee provision starts that over at any time.

The server log says what each pass changed, under [config], and Instance status shows the last pass under Configuration file.

The file

A complete example ships with Bouée as config/bouee.example.yaml:

version: 1

staff:
  - alex@example.com                  # or { email: alex@example.com, name: Alex Kim }

hosted_domain: example.com            # brands' own subdomains go under it
home_page: teams                      # the root address: teams, or a workspace's name or id

email:
  sender_address: no-reply@mail.example.com   # sign-in codes; defaults to the first workspace's address
  sender_name: Example
  region: us-east-1                   # the Resend region for the domains this file adds

workspaces:
  - name: IT
    id: 4f1c9a7e2b3d4c5e8f901a2b3c4d5e6f   # optional; openssl rand -hex 16
    owners: [alex@example.com]
    agents: [sam@example.com]
    team_domains: [example.com]       # anyone at these may join as an agent
    brand:
      name: IT help desk
      subdomain: it                   # it.example.com: its knowledge base and mailbox
      support_address: help@it.example.com
      sender_name: IT help desk
      portal:
        title: IT support
        intro: Ask for help with laptops, accounts and access.
        access: email_link            # or public
      help_center:
        title: IT help center
        intro: Answers to the questions we hear most.

apps:
  - name: Nightly export
    client_id: bcl_nightly_export
    secret_env: NIGHTLY_EXPORT_SECRET
    workspace: IT                      # leave out for an account app
    scopes: [tickets:read]

How it is applied

The file adds and changes, and never takes anything away:

  • Nothing is deleted. A workspace, member, domain or app taken out of the file stays; remove it in the console.
  • A setting the file names is set back to the file's value at every start, so change it in the file. Everything the file leaves out stays as the console has it.
  • Roles only go up. The file makes someone staff, or an owner; it never makes an owner an agent again.
  • Lists gain the file's entries and keep the console's: a team domain added in the console stays.
  • Someone a workspace owner removed is not added back, and the file's run says so. Take them out of the file, or add them again in the console.
  • A brand's subdomain is permanent once reserved, as it is in the console: it is the brand's web address and mailbox at once. A different one in the file is reported and left alone.

Each problem names the setting it came from, such as workspaces[0].brand.subdomain, and the rest of the file is still applied. The schema is strict: a misspelled setting is an error, never a setting that silently does nothing.

The settings

SettingWhat it does
staffPeople who run the installation: Instance settings, the email provider, every workspace. An address, or { email, name }.
hosted_domainThe domain brands' subdomains go under (Brand hostnames). It can no longer change once a brand has reserved a subdomain under it.
home_pageWhat the root address shows: teams, the directory of workspaces, or the name or id of a workspace in the file, whose help center it then shows.
email.sender_address, email.sender_nameWhere sign-in codes come from. Left out, the first workspace's support address sends them, as first-run setup would choose.
email.regionThe Resend region for the domains the file adds: us-east-1, eu-west-1, sa-east-1 or ap-northeast-1.
workspaces[].nameThe workspace's name.
workspaces[].idOptional: 32 lowercase hexadecimal characters, the id in its console URLs. With it, a rename in the console still finds this workspace; without it, the file finds the workspace by name, and makes a new one after a rename.
workspaces[].owners, agentsMembers. Each is made an account if they have none, and signs in with an email code. A workspace the file gives no owner is owned by the file's staff.
workspaces[].team_domainsAnyone at these domains may join as an agent when they first sign in. Free email providers are refused.
brand.nameThe workspace's brand, which customers see.
brand.subdomainThe brand's hosted address under hosted_domain: its knowledge base's website, and its support mailbox unless support_address names another.
brand.support_addressWhere customers write; replies come from it. Mail to any address at its domain becomes a ticket, so use a subdomain that receives nothing else.
brand.sender_nameThe name replies are sent under.
brand.portalThe customer portal's title, intro and access: email_link, where people prove their address, or public, open to anyone with the link. Company sign-in is set up in the console.
brand.help_centerThe knowledge base's title and intro.
apps[]Apps that use the API as themselves. See apps.

The file covers each workspace's default brand. More brands, company sign-in, forwarding from an existing mailbox and custom hostnames are set up in the console, and the file leaves them as they are.

Apps

An app in the file uses the client-credentials grant, as one made on the API pages does: a workspace app with workspace, an account app without it. Its client_id is chosen in the file, so an integration can be configured before Bouée starts, and must start with bcl_. Its client secret is read from the environment variable secret_env names, never from the file:

export NIGHTLY_EXPORT_SECRET=$(openssl rand -hex 32)
bin/install --domain support.example.com --config ~/bouee.yaml --env NIGHTLY_EXPORT_SECRET --yes

Only the secret's SHA-256 is stored. Put a new value in .env and apply the file again to replace it: the old secret stops working at once, and so do the access tokens issued with it.

Resend with an API key

Setup in the browser connects Resend by OAuth. An installation set up from a file has no browser to approve that with, so Bouée can use an API key instead:

  1. In Resend, create an API key with Full access. A sending-only key cannot add domains, read incoming mail or create webhooks, and Bouée refuses it with that reason.
  2. Put it in .env as RESEND_API_KEY, or export it before bin/install, which writes it there.

While RESEND_API_KEY is set it replaces the OAuth connection, and Instance settings shows the mail connection as connected by API key. To use OAuth instead, remove the key and restart. The key is Bouée's own credential for Resend. It is not a way into Bouée's API, which stays OAuth only.

bin/bouee

bin/bouee asks the running server over a socket inside its container, so it needs no port, password or network access: whoever can run docker compose exec on the server can use it.

CommandWhat it does
bin/bouee statusWhether the installation works end to end, and what it still waits for. Exits 0 when ready, 1 when not.
bin/bouee status --wait 1800Asks again every ten seconds until it is ready, or for up to 1,800 seconds.
bin/bouee dns-recordsEvery DNS record the installation's domains need, and whether each is published: DKIM, SPF and MX from Resend, a DMARC policy where there is none, and the address record each hosted knowledge base needs.
bin/bouee provisionApplies the file now, as a start does. Exits 1 when it asks for something that could not be done.

Each takes --json for scripts. "Ready" means Resend is connected and usable, the sign-in sender is verified for sending with a DMARC policy, and every workspace with a support address sends and receives, webhook included.

Changing things later

Edit config/bouee.yaml and apply it; no restart is needed:

bin/bouee provision

bin/upgrade applies it again as well, since every start does.

Examples

cloud-init

User data for a new Ubuntu server. The Resend key and the app secret come from wherever you keep secrets; don't commit them with the file.

#cloud-config
package_update: true
packages: [git, openssl]
write_files:
  - path: /root/bouee.yaml
    content: |
      version: 1
      staff: [alex@example.com]
      workspaces:
        - name: Support
          brand:
            support_address: help@support.example.com
runcmd:
  - curl -fsSL https://get.docker.com | sh
  - git clone "https://github.com/your-org/bouee.git" /opt/bouee
  - cd /opt/bouee && RESEND_API_KEY="re_..." bin/install --domain support.example.com --config /root/bouee.yaml --yes

A deploy from GitHub Actions

Upgrades the server and waits until mail works, failing the job if it does not within half an hour:

name: Deploy Bouée
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Upgrade and apply bouee.yaml
        run: |
          install -m 600 /dev/null key && echo "$SSH_KEY" > key
          ssh -i key -o StrictHostKeyChecking=accept-new deploy@support.example.com \
            'cd /opt/bouee && git pull --ff-only && bin/upgrade && bin/bouee status --wait 1800'
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}

Publishing the DNS records with Cloudflare

bin/bouee dns-records --json lists each record with its type, name, content, priority and status, so a script can publish what is missing. This one uses the Cloudflare API with a token that may edit the zone:

bin/bouee dns-records --json |
  jq -c '.records[] | select(.status != "verified" and .status != "present" and .status != "inherited")' |
  while read -r record; do
    curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
      -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
      --data "$(jq -c '{type, name, content, ttl: 1, proxied: false} + (if .priority then {priority} else {} end)' <<< "$record")"
  done
bin/bouee provision   # asks Resend to check the records now

Check what it would add before running it against a zone that serves other things: it adds records, and never changes or removes one, so a record that conflicts with an existing one fails rather than replacing it.

On this page