Skip to content

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.net name and refuses to run without it.
  • HTTPS Certificates on. DNS page, under MagicDNS. tailscale serve needs 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:server on 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:

openssl rand -hex 32
ansible-vault edit inventory/group_vars/all/vault.yml

Add one line with the value you just generated, save, and close:

vault_n8n_encryption_key: "<the 64 hex characters>"

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):

all:
  hosts:
    lite-dev:
      # ... unchanged
  children:
    n8n:
      hosts:
        lite-dev:

4. Run it

ansible-playbook playbooks/n8n.yml --limit lite-dev

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 though octo-ctl is 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" shows 127.0.0.1:5678 (docker-proxy) and the tailnet address on 443 (tailscaled). Never 0.0.0.0 or * 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:

  1. Gmail API enabled (APIs & Services, Library). No billing needed.
  2. Google Auth Platform, audience External (Internal is only offered inside a Workspace organisation).
  3. Data Access: one scope only, https://www.googleapis.com/auth/gmail.modify.
  4. 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.
  5. Branding, needed before it will publish: home page https://octopodia.com.au, privacy policy https://octopodia.com.au/pages/privacy.html, authorised domains octopodia.com.au and tail34193f.ts.net. No logo: a logo forces Google's verification review.
  6. 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

  1. Read the release notes for every version between the current pin and the new one.
  2. Back up the data folder (section 7).
  3. Change n8n_version in roles/n8n/defaults/main.yml, commit, merge to main.
  4. 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:

  1. The file Ansible wrote: sudo grep ^EXECUTIONS_ /opt/n8n/n8n.env
  2. 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.
  3. 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).
  4. 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