Run appointment texts on your own server with n8n
Set up n8n on a server of your own with Docker, Caddy and Postgres, install the BlueBot community node, and build two workflows. One texts a reminder the evening before each appointment, and the other answers a reply of C or R by confirming it or sending the booking link.
Tom owns Fade Street Barbers, and his booking site keeps appointments in a Postgres database on a small server he rents. He wants every client texted the evening before, from the shop’s own number, with a way to confirm by replying, and he wants the clients’ names and times to stay on his server. With this guide he runs n8n next to the database, and two workflows do the work. At 5 PM, each of tomorrow’s clients gets a reminder. A reply of C marks the appointment confirmed and says thanks, and a reply of R sends the booking link.
Only the texts themselves pass through BlueBot and your Mac. The schedule, the database and the logic stay on your server.
What you’ll have
- n8n running in Docker on your server at an
httpsaddress, with its own certificate. - A Postgres table of appointments, which your booking site (or you) fills in.
- A workflow that texts tomorrow’s clients at 5 PM and records which text went to whom.
- A workflow that acts on replies of C and R as they arrive.
What you need
- A BlueBot account with your Mac connected.
- A Linux server with Docker and Docker Compose, ports 80 and 443 open, and a domain name you can add a DNS record to. Any small cloud server does.
- Twenty minutes.
Point a name at the server
At your DNS host, add an A record for n8n.fadestreet.example (use your own domain) pointing at the server’s public IP address. Caddy, below, gets a certificate for that name on its own, which BlueBot needs: it only posts webhooks to public https addresses with a real certificate.
Write the three files
On the server, make a folder called barbers and put these in it.
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
n8n:
image: docker.n8n.io/n8nio/n8n # pin the version you tested, such as :1.x.y
restart: unless-stopped
environment:
- N8N_HOST=n8n.fadestreet.example
- N8N_PROTOCOL=https
- N8N_PORT=5678
- WEBHOOK_URL=https://n8n.fadestreet.example/
- N8N_PROXY_HOPS=1
- GENERIC_TIMEZONE=America/New_York
- TZ=America/New_York
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
volumes:
- n8n_data:/home/node/.n8n # credentials, workflows and community nodes live here
postgres:
image: postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=barbers
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=bookings
volumes:
- pg_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
volumes:
caddy_data:
caddy_config:
n8n_data:
pg_data:n8n.fadestreet.example {
reverse_proxy n8n:5678
}CREATE TABLE appointments (
id serial PRIMARY KEY,
first_name text NOT NULL,
phone text NOT NULL, -- any format; replies are matched on the last 10 digits
barber text NOT NULL,
starts_at timestamptz NOT NULL,
reminder_message_id text, -- the BlueBot msg_ id of the reminder, once it's sent
confirmed_at timestamptz,
canceled_at timestamptz
);A few things in those files matter:
WEBHOOK_URLis the address n8n gives BlueBot for its trigger. Without it, n8n registershttp://localhost:5678/…, which BlueBot refuses.GENERIC_TIMEZONEis the zone the 5 PM schedule runs in. Use the shop’s.- Postgres has no
ports:line, so only n8n can reach it, over Docker’s own network. N8N_ENCRYPTION_KEYencrypts the credentials n8n stores, your BlueBot key among them. Keep it with your backups; without it, a restored n8n can’t read them.
Put the two secrets in a .env file next to docker-compose.yml, which Compose reads on its own:
printf 'N8N_ENCRYPTION_KEY=%s\nPOSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 24)" > .env
chmod 600 .envStart it
docker compose up -d
docker compose logs -f caddy n8n # Ctrl+C once n8n says it's readyOpen https://n8n.fadestreet.example and set up the owner account n8n asks for. The address shows a padlock, which means Caddy got its certificate.
Install the BlueBot node
In n8n, go to Settings → Community Nodes, press Install a community node, type n8n-nodes-bluebot, tick the box about the risks of community nodes and press Install. It’s stored in the n8n_data volume, so it survives restarts and new images.
Connect n8n to BlueBot and to the database
In the BlueBot dashboard, open Developers → API keys and make a key named “n8n” with the scopes messages:send (to text), webhooks:manage (the trigger makes its own webhook) and account:read (n8n tests the credential with it).
In n8n, open Credentials and make two:
| Credential | Fields |
|---|---|
| BlueBot API | API Key: your bb_live_… key. Leave Base URL as it is. Saving tests it, and shows Connection tested successfully. |
| Postgres | Host postgres, Database bookings, User barbers, Password from .env, Port 5432, SSL Disable (the database never leaves the Docker network). |
Build the reminder workflow
Make a workflow called “Tomorrow’s reminders” with four nodes in a row.
-
Schedule Trigger: Trigger Interval Days, Days Between Triggers
1, Trigger at Hour 5pm, Trigger at Minute0. -
Postgres, renamed “Tomorrow’s appointments”: Operation Execute Query, with this query. It finds tomorrow’s appointments in the shop’s zone that haven’t had a reminder, and formats the time for the text:
sqlSELECT id, first_name, phone, barber, to_char(starts_at AT TIME ZONE 'America/New_York', 'FMHH12:MI AM') AS time FROM appointments WHERE canceled_at IS NULL AND reminder_message_id IS NULL AND (starts_at AT TIME ZONE 'America/New_York')::date = (now() AT TIME ZONE 'America/New_York')::date + 1 ORDER BY starts_at; -
BlueBot: Resource Message, Operation Send Text, Send To Person, To
{{ $json.phone }}, and this Text:TextHi {{ $json.first_name }}, this is Fade Street Barbers. You're booked with {{ $json.barber }} tomorrow at {{ $json.time }}. Reply C to confirm or R to pick a new time.Under Add Field, add Metadata with the key
appointment_idset to{{ $json.id }}, and Idempotency Key set toreminder-{{ $json.id }}. -
Postgres, renamed “Remember the reminder”: Operation Execute Query, query
UPDATE appointments SET reminder_message_id = $1 WHERE id = $2;, and under Options → Query Parameters,{{ $json.id }},{{ $json.metadata.appointment_id }}. The BlueBot node outputs the text it accepted, so$json.idis itsmsg_id and its metadata carries the appointment back.
The node sends one text per row, and your sending rules space them out, one every 20 seconds by default. The idempotency key means that if the workflow runs twice in a day, BlueBot answers the second send with the first text rather than texting the client again, and reminder_message_id IS NULL keeps the query from picking them up a second time at all.
Build the reply workflow
Make a second workflow called “Replies to reminders”.
-
BlueBot Trigger: Events Message Received, Output Record. Its output is the text that came in, with
from,textandid. -
Postgres, renamed “Their next appointment”: Operation Execute Query, Query Parameters
{{ $json.from }}, and this query, which finds the client’s next reminded appointment by the last ten digits of their number:sqlSELECT id, first_name FROM appointments WHERE right(regexp_replace(phone, '\D', '', 'g'), 10) = right(regexp_replace($1, '\D', '', 'g'), 10) AND reminder_message_id IS NOT NULL AND canceled_at IS NULL AND starts_at > now() ORDER BY starts_at LIMIT 1;A text from somebody with no upcoming appointment finds no row, and the workflow stops there for that text.
-
Switch: Mode Rules, two routing rules on the value
{{ ($('BlueBot Trigger').item.json.text ?? '').trim().toUpperCase() }}: is equal toCfor output 0, and is equal toRfor output 1. Any other text goes nowhere and waits for you on your phone. -
On output 0, Postgres: Execute Query
UPDATE appointments SET confirmed_at = now() WHERE id = $1 RETURNING id, first_name;with Query Parameters{{ $json.id }}. Then BlueBot, Send Text: To{{ $('BlueBot Trigger').item.json.from }}, TextThanks {{ $json.first_name }}, you're confirmed. See you tomorrow., Idempotency Keyconfirm-{{ $('BlueBot Trigger').item.json.id }}. -
On output 1, BlueBot, Send Text: To
{{ $('BlueBot Trigger').item.json.from }}, TextNo problem, {{ $json.first_name }}. Pick a new time at https://fadestreet.example/book and we'll free up your old one., Idempotency Keyreschedule-{{ $('BlueBot Trigger').item.json.id }}.
The idempotency keys come from the incoming text’s id, so a client who gets a webhook delivered twice still gets one answer.
Activate the workflow with the toggle at the top. The trigger makes its webhook in your BlueBot account then, at https://n8n.fadestreet.example/webhook/…, and you’ll see it in Developers → Webhooks. Deactivating the workflow deletes it.
Test it with your own number
Add an appointment for yourself tomorrow, with your own mobile number:
docker compose exec postgres psql -U barbers -d bookings -c \
"INSERT INTO appointments (first_name, phone, barber, starts_at)
VALUES ('Tom', '+15555550123', 'Luis',
((now() AT TIME ZONE 'America/New_York')::date + 1 + time '15:00') AT TIME ZONE 'America/New_York');"Open “Tomorrow’s reminders” and run it once by hand with Execute workflow at the bottom of the canvas (some n8n versions call it Test workflow). Then reply C from your phone.
| You got | It means | Do next |
|---|---|---|
The reminder on your phone, and reminder_message_id filled in on your row | The reminder workflow works. | Reply C. |
“Thanks Tom, you’re confirmed.” and confirmed_at filled in | The reply workflow works. | Delete your test row, and leave both workflows active. |
| The reminder workflow ran but sent nothing | The query found no row for tomorrow. | Check the row’s starts_at and that the zone in the query is the shop’s. |
| “BlueBot couldn’t reach this URL” when you activate the reply workflow | BlueBot refused n8n’s webhook address. | Check WEBHOOK_URL is your https address and that the padlock shows, then deactivate and activate again. |
| Your C reply arrived but nothing answered | The trigger didn’t fire, or the query found no reminded appointment. | Open the workflow’s Executions list. A run that stops at “Their next appointment” found no row. |
The reminder is in BlueBot with a hold | It’s accepted and waiting, often for quiet hours or your Mac. | It goes when the hold ends. |
When it goes wrong
- Reminders go out at the wrong hour. The Schedule Trigger uses
GENERIC_TIMEZONE, and the query uses the zone written in it. Set both to the shop’s zone and restart n8n withdocker compose up -d. - A client says STOP. BlueBot puts them on your stop list, and the next reminder to them comes back from the BlueBot node with
state: "canceled"andcancel_reason: "opted_out". The workflow still records its id, so they aren’t tried again. - You moved n8n to a new server or a new address. Bring the
n8n_datavolume and the sameN8N_ENCRYPTION_KEY, updateWEBHOOK_URLand the DNS record, then deactivate and activate the reply workflow so the trigger makes a webhook at the new address. - You deleted the reply workflow while it was active. Its webhook can stay behind in BlueBot. Delete it in Developers → Webhooks.
- The server was down at 5 PM. n8n doesn’t run a missed schedule later. Run the reminder workflow by hand when it’s back; the query and the idempotency keys keep anyone from getting two.
- Backups. Back up the
pg_dataandn8n_datavolumes and keep.envsomewhere safe.docker compose exec postgres pg_dump -U barbers bookings > bookings.sqlis a start.