n8n for the Email Assistant, Junior¶
How to put n8n on a hardened host for the Email Assistant, Junior.
First used on lite-dev. Run it unchanged for the restore rehearsal host and for each Junior client.
The inventory moved, 16 October 2026
The live inventory is now its own private repository, octopodia-abos-inventory, cloned beside the code
(ADR-0042). Paths below written as
infra/inventory/... or inventory/... are now in that repository, and secrets no longer go in
group_vars/all/vault.yml: a host's go in host_vars/<host>/vault.yml (vault ID core), a tenant's in
tenants/<slug>/vault.yml (the tenant's own vault ID), each value added with ansible-vault encrypt_string.
See the inventory repository runbook.
What you end up with: n8n, pinned version, SQLite, reachable only over the tailnet at
https://<host>.<tailnet>.ts.net/. Nothing is open to the internet. Gmail is polled, so nothing
outside needs to reach in.
Before this: the host has been through all three phases of host setup, and
tailnet.yml has run. Run everything below from octo-ctl, on main.
1. One-time tailnet settings (admin console)¶
Once per tailnet, not per host.
- MagicDNS on. DNS page. The role reads the host's
*.ts.netname and refuses to run without it. - HTTPS Certificates on. DNS page, under MagicDNS.
tailscale serveneeds it to put HTTPS on the name. This publishes the machine names in the public certificate transparency logs. The names are not secret, nothing is reachable through them from outside the tailnet, but do not put a client's name in a hostname because of it. - Access rule for HTTPS. The tailnet policy must let admins reach
tag:serveron port 443, alongside the existing SSH port. Check the access controls page before the first run.
2. The encryption key: once per n8n instance, before the first start¶
n8n encrypts every stored credential (the Gmail sign-in, the AI key) with this key.
Lose it, or change it, and every connection must be re-authorised
Generate it once. Store it in the vault and in Bitwarden before n8n first starts. A restore onto another host needs the same key, so the Bitwarden copy is not optional.
On octo-ctl, in ~/octopodia-abos/infra:
Add one line with the value you just generated, save, and close:
Then store the same value in the Bitwarden shared collection as "n8n encryption key: lite-dev",
and clear your terminal (clear).
The key belongs to this host's n8n. group_vars/all is right only while lite-dev is the only host:
a second host would inherit the same key, so its key goes in inventory/host_vars/<host>/vault.yml
(known trap 15).
Note: one key in group_vars/all suits the test host and its restore rehearsal, which must share
it. Each client gets its own key in that host's host_vars vault file when Junior goes to clients.
3. Add the host to the n8n group¶
In the inventory on octo-ctl (inventory/hosts.yml, not in git), keep the host's entry as it is
and add the group. children: sits inside all:, two spaces in, level with hosts:. At the left margin it becomes a group literally called "children" and Ansible warns Skipping unexpected key (n8n):
4. Run it¶
Check the group first: ansible-inventory --graph must show @n8n: with the host under it.
It installs Docker (if missing), writes the config, starts n8n, waits for it to answer, opens HTTPS
on the tailnet interface only, and turns on tailscale serve. The last lines print the address.
5. Straight after the first run¶
- Create the owner account immediately. The first person to open the address becomes the owner. Only tailnet devices can reach it, but do it now regardless. Use a Bitwarden-generated password and store it there as "n8n owner: lite-dev".
-
Check it is not public. From
octo-ctl, against the host's public IP (the request goes over the internet even thoughocto-ctlis on the tailnet):curl -sS -m 5 -k https://PUBLIC_IP/ ; echo "--- 443 done" curl -sS -m 5 http://PUBLIC_IP:5678/ ; echo "--- 5678 done"Both must fail. 443 says Could not connect (the firewall allows 443 publicly for Caddy later, but nothing listens there); 5678 times out. Any HTML back means n8n is public: stop. - [ ] On the host:
sudo ss -tlnp | grep -E ":5678|:443"shows127.0.0.1:5678(docker-proxy) and the tailnet address on 443 (tailscaled). Never0.0.0.0or*in the local column.
First run on lite-dev, 8 October 2026: all of the above passed first time on Ubuntu 26.04,
Docker 29.8.2, Compose v5.6.0, n8n 2.42.4.
Reaching it from your own device¶
Your device must be on the company tailnet, signed in with your company login. For lite-dev
only, El's personal tailnet has the machine shared into it (October 2026), as a stopgap. A
production host is never shared out: see OI-086.
5a. Connect Gmail and the AI (the test account)¶
Done once per n8n instance. For lite-dev this is the test mailbox admin.octopodia@gmail.com,
a plain Gmail account. A client's setup differs: their Workspace admin creates an Internal app,
which needs no publishing, no verification and never expires sign-ins (see the
Junior page, onboarding step 1).
Google Cloud, in the test account's browser profile, project octopodia-junior-test:
- Gmail API enabled (APIs & Services, Library). No billing needed.
- Google Auth Platform, audience External (Internal is only offered inside a Workspace organisation).
- Data Access: one scope only,
https://www.googleapis.com/auth/gmail.modify. - Clients: Web application, redirect URI exactly
https://lite-dev.tail34193f.ts.net/rest/oauth2-credential/callback. Client ID and secret into Bitwarden as Google OAuth client: n8n lite-dev straight away. - Branding, needed before it will publish: home page
https://octopodia.com.au, privacy policyhttps://octopodia.com.au/pages/privacy.html, authorised domainsoctopodia.com.auandtail34193f.ts.net. No logo: a logo forces Google's verification review. - Audience: Publish app, before connecting n8n. In Testing, Google expires the sign-in after 7 days; a sign-in made while in Testing keeps that expiry even after publishing. Unverified is fine for our own single account: you click past Google hasn't verified this app once.
n8n, Credentials:
| Credential | Settings that matter |
|---|---|
| Gmail: admin.octopodia (test) (Gmail OAuth2 API) | Check the OAuth Redirect URL shown matches step 4 exactly. Custom Scopes on, gmail.modify only: the default set includes full mailbox access and sending, which the Junior must never have. Allowed HTTP Request Domains: gmail.googleapis.com only. Every Gmail call is an HTTP Request node in the mailbox workflow, so None would block them all. Checked on lite-dev, 10 October 2026 (OI-093, closed). |
| Graph: ABOS alerts (lite-dev) (OAuth2 API, client credentials) | Sends Junior alerts as alerts@octopodia.com.au, from Junior: watchdog. Allowed HTTP Request Domains: graph.microsoft.com only. Set up per Alert delivery, section 5. Created 11 October 2026; a forced alert was emailed on open and on resolve |
| Gemini: Junior test (Google Gemini(PaLM) API) | Key from AI Studio, created in the same Cloud project and restricted to the Gemini API only. Bitwarden: Gemini API key: Junior test |
The free Gemini tier may use what it is sent to improve Google's products
Fine for made-up test email, and the reason this key must never see a real inbox. Clients run on the paid model, which is what makes the product sheet's does not keep it or train on it true.
Done 8 October 2026: all of the above, first time, including the OAuth redirect over the tailnet.
6. Upgrading n8n¶
- Read the release notes for every version between the current pin and the new one.
- Back up the data folder (section 7).
- Change
n8n_versioninroles/n8n/defaults/main.yml, commit, merge tomain. - On
octo-ctl:git pull, then re-run the playbook. The container is recreated; the data folder is kept.
7. Backups (not yet in place)¶
Everything this instance holds is in /opt/n8n/data: the SQLite database, which contains the
workflows, the encrypted credentials and the execution history. The workflow JSON is also kept in
git. Until the backup role covers this folder, lite-dev holds nothing that cannot be rebuilt,
so test data only. Tracked as OI-087.
Execution history¶
We keep a record of what was sorted, never what the email said. A saved n8n execution holds everything the run touched, the subject and the body sent to the AI included, so the role sets (OI-094, decided 10 October 2026, the same on every host):
| Role variable | Default | Environment variable | Means |
|---|---|---|---|
n8n_save_on_success |
none |
EXECUTIONS_DATA_SAVE_ON_SUCCESS |
Successful runs are not saved |
n8n_save_on_error |
all |
EXECUTIONS_DATA_SAVE_ON_ERROR |
Failed runs are saved, to diagnose them. They may hold the email that failed |
n8n_save_manual |
true |
EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS |
Manual (test) runs are saved |
n8n_prune |
true |
EXECUTIONS_DATA_PRUNE |
Saved runs are deleted on a rolling basis |
n8n_max_age_hours |
168 |
EXECUTIONS_DATA_MAX_AGE |
After 7 days |
n8n_executions_max_count |
10000 |
EXECUTIONS_DATA_PRUNE_MAX_COUNT |
Backstop on the number kept |
Names checked against n8n 2.42.4. A workflow's own settings can override the first three (Settings, Save successful production executions): leave every Junior workflow on Default. n8n Insights still counts runs that are not saved (OI-089).
Check it on a host:
- The file Ansible wrote:
sudo grep ^EXECUTIONS_ /opt/n8n/n8n.env - What the running container has:
sudo docker exec n8n env | grep ^EXECUTIONS_. If this differs from step 1, the container was not recreated: re-run the playbook. - In n8n, Executions: after the next few polls, no new successful production runs appear. Failed runs and manual runs still do. Runs older than 7 days are gone (pruning runs hourly, so allow an hour or two past the 7 days).
- Each Junior workflow, Settings: the three Save ... executions options read Default.
What does not change it: runs saved before the change stay until they age out (7 days from the change at most). Pruning deletes the execution rows; SQLite reuses the space rather than shrinking the file.
If something goes wrong¶
| Symptom | Cause | Fix |
|---|---|---|
| Role stops at "no MagicDNS name" | MagicDNS off, or the host is not on the tailnet | Section 1, or run tailnet.yml |
| "Serve n8n over HTTPS" fails mentioning HTTPS | HTTPS Certificates not enabled | Section 1 |
| "Wait for n8n to answer" times out, logs say Mismatching encryption keys | The data folder was created with a different key | Do not change the key to match. Put back the key from Bitwarden. Only a brand-new, empty data folder may take a new key |
| Address loads on the host but not from your laptop | Access rule missing for port 443 | Section 1, access rule |
| Gmail sign-in fails on the return to n8n | The signing-in browser is not on the tailnet, or N8N_HOST is wrong |
Sign in from a tailnet device; check the address in the role's report |