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 inbin/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_KEYinto.envwhen 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
- 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.
- 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.
- Whatever is still pending, usually DNS records Resend has not verified yet, is tried again every five
minutes for a day.
bin/bouee provisionstarts 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
| Setting | What it does |
|---|---|
staff | People who run the installation: Instance settings, the email provider, every workspace. An address, or { email, name }. |
hosted_domain | The domain brands' subdomains go under (Brand hostnames). It can no longer change once a brand has reserved a subdomain under it. |
home_page | What 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_name | Where sign-in codes come from. Left out, the first workspace's support address sends them, as first-run setup would choose. |
email.region | The Resend region for the domains the file adds: us-east-1, eu-west-1, sa-east-1 or ap-northeast-1. |
workspaces[].name | The workspace's name. |
workspaces[].id | Optional: 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, agents | Members. 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_domains | Anyone at these domains may join as an agent when they first sign in. Free email providers are refused. |
brand.name | The workspace's brand, which customers see. |
brand.subdomain | The brand's hosted address under hosted_domain: its knowledge base's website, and its support mailbox unless support_address names another. |
brand.support_address | Where 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_name | The name replies are sent under. |
brand.portal | The 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_center | The 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 --yesOnly 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:
- 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.
- Put it in
.envasRESEND_API_KEY, or export it beforebin/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.
| Command | What it does |
|---|---|
bin/bouee status | Whether the installation works end to end, and what it still waits for. Exits 0 when ready, 1 when not. |
bin/bouee status --wait 1800 | Asks again every ten seconds until it is ready, or for up to 1,800 seconds. |
bin/bouee dns-records | Every 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 provision | Applies 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 provisionbin/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 --yesA 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 nowCheck 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.