# 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](https://usebluebot.com/docs/concepts/how-it-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.

```yaml title="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:
```

```text title="Caddyfile"
n8n.fadestreet.example {
    reverse_proxy n8n:5678
}
```

```sql title="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:

```bash title="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

```bash
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](https://usebluebot.com/docs/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:

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

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](https://usebluebot.com/docs/concepts/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.

> **Why C and not YES.** YES is one of BlueBot’s start words: it takes somebody off your stop list. A one-letter answer such as C can’t be mistaken for that, or for STOP. [Stop words](https://usebluebot.com/docs/concepts/stop-words)

### Test it with your own number

Add an appointment for yourself tomorrow, with your own mobile number:

```bash
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. [Holds](https://usebluebot.com/docs/concepts/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](https://usebluebot.com/docs/concepts/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

- [n8n](https://usebluebot.com/docs/n8n): Every operation in the BlueBot node, the trigger’s options, and calling the API without the node.
- [Appointment reminders](https://usebluebot.com/docs/guides/appointment-reminders): Schedule each reminder when the booking is made, with `send_at`, instead of a nightly run.
- [Keyword auto-reply](https://usebluebot.com/docs/guides/keyword-auto-reply): Answer C, R or HOURS with a rule in BlueBot, with no workflow at all.
- [n8n recipes](https://usebluebot.com/docs/recipes/n8n): More n8n workflows with BlueBot.
