Skip to the page

Guides

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. How BlueBot works

What you’ll have

  • n8n running in Docker on your server at an https address, 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.

docker-compose.yml
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:
Caddyfile
n8n.fadestreet.example {
    reverse_proxy n8n:5678
}
init.sql
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_URL is the address n8n gives BlueBot for its trigger. Without it, n8n registers http://localhost:5678/…, which BlueBot refuses.
  • GENERIC_TIMEZONE is 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_KEY encrypts 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:

Make the .env file
printf 'N8N_ENCRYPTION_KEY=%s\nPOSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 24)" > .env
chmod 600 .env

Start it

Terminal
docker compose up -d
docker compose logs -f caddy n8n   # Ctrl+C once n8n says it's ready

Open 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. n8n

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:

CredentialFields
BlueBot APIAPI Key: your bb_live_… key. Leave Base URL as it is. Saving tests it, and shows Connection tested successfully.
PostgresHost 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.

  1. Schedule Trigger: Trigger Interval Days, Days Between Triggers 1, Trigger at Hour 5pm, Trigger at Minute 0.

  2. 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:

    sql
    SELECT 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;
  3. BlueBot: Resource Message, Operation Send Text, Send To Person, To {{ $json.phone }}, and this Text:

    Text
    Hi {{ $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_id set to {{ $json.id }}, and Idempotency Key set to reminder-{{ $json.id }}.

  4. 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.id is its msg_ 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. Idempotency

Build the reply workflow

Make a second workflow called “Replies to reminders”.

  1. BlueBot Trigger: Events Message Received, Output Record. Its output is the text that came in, with from, text and id.

  2. 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:

    sql
    SELECT 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.

  3. Switch: Mode Rules, two routing rules on the value {{ ($('BlueBot Trigger').item.json.text ?? '').trim().toUpperCase() }}: is equal to C for output 0, and is equal to R for output 1. Any other text goes nowhere and waits for you on your phone.

  4. 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 }}, Text Thanks {{ $json.first_name }}, you're confirmed. See you tomorrow., Idempotency Key confirm-{{ $('BlueBot Trigger').item.json.id }}.

  5. On output 1, BlueBot, Send Text: To {{ $('BlueBot Trigger').item.json.from }}, Text No problem, {{ $json.first_name }}. Pick a new time at https://fadestreet.example/book and we'll free up your old one., Idempotency Key reschedule-{{ $('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:

Terminal
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 gotIt meansDo next
The reminder on your phone, and reminder_message_id filled in on your rowThe reminder workflow works.Reply C.
“Thanks Tom, you’re confirmed.” and confirmed_at filled inThe reply workflow works.Delete your test row, and leave both workflows active.
The reminder workflow ran but sent nothingThe 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 workflowBlueBot 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 answeredThe 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 holdIt’s accepted and waiting, often for quiet hours or your Mac.It goes when the hold ends. Holds

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 with docker 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" and cancel_reason: "opted_out". The workflow still records its id, so they aren’t tried again. Stop words
  • You moved n8n to a new server or a new address. Bring the n8n_data volume and the same N8N_ENCRYPTION_KEY, update WEBHOOK_URL and 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_data and n8n_data volumes and keep .env somewhere safe. docker compose exec postgres pg_dump -U barbers bookings > bookings.sql is a start.

Next