Can I self-host Acuity Scheduling?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Easy!Appointments. It takes one prompt, a 1536 MB VPS, and about 90 minutes. That is $34 a month you stop paying Acuity Scheduling — $408 a year on the Standard plan.

Why people pay for Acuity Scheduling

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Acuity sells a front desk that never closes. The booking page renders on a stranger's phone, it knows which of your staff works Tuesdays, it converts time zones without anyone thinking about it, and it can take the deposit before the appointment exists. The money is attached to the parts that are genuinely hard: reminder email and text that arrive, payment processing already wired in, and calendar connectors that keep working after Google or Microsoft changes something. There is no free tier, only a 7-day trial, so a booking habit starts costing from the first week.

Acuity Scheduling plans and list prices
PlanList priceWhat it buys
Starter$20/moOne calendar. $16/month on annual billing, which the pricing page shows by default under a 20% saving.
Standardthe plan this page prices against$34/moUp to 6 calendars, which is the first tier that fits a shop with staff. $27/month on annual billing. Adds text reminders and memberships.
Premium$61/moUp to 36 calendars. $49/month on annual billing. The tier that carries the HIPAA agreement, removal of Acuity branding, and API and CSS access.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high

Replaced by Easy!Appointments

One project, named before the prompt, so you know what you are about to install.

A front-desk diary on your own domain: services, providers, working hours, and customers who book without creating an account.

The same shape of product rather than a smaller cousin: services with a duration and a price, providers with their own working plans, a public booking page a customer uses without creating an account, and a calendar the front desk actually watches. The install is modest by this catalogue's standards, two containers and a MySQL, and the honest cost is what it does not do. There are no payments, no text reminders, and no email at all until you wire in a relay, which matters more here than it sounds: the confirmation screen tells your customer their details were emailed to them.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Easy!Appointmentsour pickONE EVENING— setup effort 2 of 4

    A front-desk diary on your own domain: services, providers, working hours, and customers who book without creating an account.

    The same shape of product rather than a smaller cousin: services with a duration and a price, providers with their own working plans, a public booking page a customer uses without creating an account, and a calendar the front desk actually watches. The install is modest by this catalogue's standards, two containers and a MySQL, and the honest cost is what it does not do. There are no payments, no text reminders, and no email at all until you wire in a relay, which matters more here than it sounds: the confirmation screen tells your customer their details were emailed to them.

  2. Cal.comONE WEEKEND— setup effort 3 of 4

    Send someone a link, they pick a slot, the invite lands in both calendars, and none of it runs on a domain you are renting.

    The better answer if what you are replacing is a personal booking link rather than a front desk. Cal.com's model is one person's availability, event types with buffers and notice periods, and a link in an email signature, and it does that far better than Easy!Appointments does. It is also the heavier install: 4 GB, a PostgreSQL, mail configured before it is useful at all, and the team and round-robin features behind a commercial licence key. Take it for your own meetings, not for a salon's diary.

The swap

You're paying

Acuity Scheduling

$34/mo · $408/yr

is replaced by

You'd run

Easy!Appointments

ONE EVENING · ~90 min to running · 1536 MB RAM

Acuity Scheduling Standard · vendor list price · checked 2026-08-06 · source

Before you start

RAM floor
1536 MBfloor from upstream docs — not measured by us yet
Disk
5 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~90 min1–3 hours, through the first backup

The prompt

Two paths to the same Easy!Appointments: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

314 lines · 14,463 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Easy!Appointments 1.6.0 on that server, reachable at https://<DOMAIN>, behind the
existing Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. Say this when you ask: `<DOMAIN>` becomes
`BASE_URL`, every booking and reschedule link is built from it, and changing it later breaks
links already in customers' inboxes.

Easy!Appointments needs 1536 MB of RAM available and 5 GB free on /srv, most of it for MySQL
8.4. Both images publish amd64 and arm64. Measure all four first:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1536 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/easyappointments /srv/easyappointments/backups
sudo install -d -m 700 /srv/easyappointments/mysql
ls -la /srv/easyappointments
```

Assert: `ls -la` shows `backups` owned by the login user and `mysql` at mode `700` owned by
root. Leave that second one alone: the MySQL image chowns its data directory to its own uid on
first start. The application container gets no volume: it writes nothing that has to
survive it. Appointments, customers, services, providers, working plans and every setting, the
company logo included, are rows in MySQL.

## 3. Secrets

Two secrets, both database passwords: the MySQL root account and the account the application
connects with. Generate both on the server. Do not print either, do not repeat them
in your summary, and do not put them in any log line. Hex rather than base64, because both
travel inside a connection string.

```bash
umask 077
cat > /srv/easyappointments/.env <<EOF
BASE_URL=https://<DOMAIN>
DB_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/easyappointments/.env
umask 022
ls -l /srv/easyappointments/.env
```

Substitute the real hostname for `<DOMAIN>` before you write the file. Assert: the file exists
with mode `-rw-------`. No administrator password is generated here. That account is created in
a browser in step 7, by the user, with a password they choose that this prompt never sees.

## 4. compose.yml

```bash
cat > /srv/easyappointments/compose.yml <<'EOF'
# Easy!Appointments · the deterministic fallback. Authored by caniselfhostit
# from the upstream documentation, not copied from a repository:
#   server image ... https://github.com/alextselegidis/easyappointments-docker/blob/master/README.md
#   entrypoint ..... https://github.com/alextselegidis/easyappointments-docker/blob/master/assets/docker-entrypoint.sh
#   install guide .. https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/installation-guide.md
#
# Two services: the PHP application and the MySQL holding every appointment,
# customer, service and setting. Upstream's own docker-compose.yml adds
# phpMyAdmin, Mailpit, Baikal and OpenLDAP, and their docs call that stack
# development only, so this file runs the image the same author publishes for
# servers. The app container gets no volume: its entrypoint rewrites config.php
# from these variables at every start. Digests read from Docker Hub on
# 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  easyappointments-db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: easyappointments
      MYSQL_USER: easyappointments
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    volumes:
      - /srv/easyappointments/mysql:/var/lib/mysql
    healthcheck:
      # -h 127.0.0.1 forces TCP: the first start runs a socket-only server.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
      interval: 10s
      start_period: 60s
      timeout: 5s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other container.

  easyappointments:
    image: alextselegidis/easyappointments:1.6.0@sha256:ab35b8872d5d3328fa3afb641a89a75f3c6f96f3fb98d6d6d3447fff9d357fa1
    restart: unless-stopped
    environment:
      # BASE_URL is the address printed inside every booking, reschedule and
      # cancel link, so it carries the https Caddy terminates out front.
      BASE_URL: ${BASE_URL}
      DEBUG_MODE: "FALSE"
      DB_HOST: easyappointments-db
      DB_NAME: easyappointments
      DB_USERNAME: easyappointments
      DB_PASSWORD: ${DB_PASSWORD}
      # Off: turning it on means an OAuth client in your own Google project.
      GOOGLE_SYNC_FEATURE: "FALSE"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8160.
      - "127.0.0.1:8160:80"
    depends_on:
      easyappointments-db:
        condition: service_healthy
EOF
cd /srv/easyappointments && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The image rewrites config.php from those environment variables
at every start, so the credentials and the base URL live in one file and nothing is edited
inside the container.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-easyappointments
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Easy!Appointments · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/alextselegidis/easyappointments-docker/blob/master/README.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASE_URL in .env, so the two have to agree.

<DOMAIN> {
	# The booking page is HTML, CSS and JavaScript, so compression pays here.
	encode zstd gzip

	header {
		# Booking page and backend share one hostname, so a downgrade on
		# either is a downgrade on both. The application sends its own
		# X-Frame-Options: SAMEORIGIN, not repeated here.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8160 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy terminates TLS
	# and speaks plain http here, which is why BASE_URL carries the https:
	# PHP reads it to mark the session cookie secure.
	reverse_proxy 127.0.0.1:8160
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-easyappointments, reload, and report what it objected to. Caddy
requests the certificate on the first request and renews it on its own, so there is nothing to
schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8160 stays closed because compose binds it to 127.0.0.1, and 3306 because
compose never publishes it: the database has no host port a rule could apply to.
Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule
for 8160 or 3306.

## 7. Start and verify

The first start initialises MySQL, a minute or two, and the application container waits on
it.

```bash
cd /srv/easyappointments
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/index.php/installation); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/index.php/installation | grep -qF 'Easy!Appointments Installation' && echo "installer OK" || echo "installer MISSING"
```

Assert both and print what you received. The loop ends printing `200`, and the second command
prints `installer OK`: that page carries the heading `Easy!Appointments Installation` over the
line `Welcome to the Easy!Appointments installation page.` If either misses, stop, run
`docker compose logs --tail 40 easyappointments` and
`docker compose logs --tail 20 easyappointments-db`, and name the likely cause: a database that
never reports healthy points at step 3, where an empty `DB_PASSWORD` leaves MySQL refusing to
initialise; a `404` instead means Caddy is not reaching the container.

That page is an open form on a public hostname: it hands the administrator account to whoever
loads it first, and it closes by being used.

STOP: tell the user to open https://<DOMAIN>/index.php/installation, enter their first name,
last name, email, a username and a password of at least 8 characters, add their company name and
company email, and press Install. Wait. Do not continue until they confirm.

Once they confirm:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/index.php/installation
curl -sS https://<DOMAIN>/ | grep -qF 'Book Appointment With' && echo "booking page OK" || echo "booking page MISSING"
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/index.php/api/v1/appointments
```

Assert all three. The first prints `307`, or any other 3xx: the installer redirects away now
that the `users` table exists, so the form is gone. A `200` there
means the account was never created and the form is still open to the internet, so stop and send
the user back. The second prints `booking page OK`, the booking page carrying the title
`Book Appointment With`. The third prints `401`, the REST API refusing a caller with no
credentials, and that is the security assert here. A running container is not success.

## 8. First backup and restore

Two artifacts. The database holds every appointment, customer and setting; the config archive
holds the files that rebuild the service around it.

```bash
cd /srv/easyappointments
docker compose exec -T easyappointments-db sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction easyappointments' | gzip > /srv/easyappointments/backups/easyappointments-db-$(date +%F).sql.gz
sudo tar -czf /srv/easyappointments/backups/easyappointments-config-$(date +%F).tar.gz -C /srv/easyappointments compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/easyappointments/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. The password is read inside
the container from its own environment, so it never reaches the host's process list; mysqldump
still warns about command-line passwords on stderr, and that line is expected. Nothing is
stopped: `--single-transaction` snapshots a running InnoDB database consistently. A backup on
the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/easyappointments
scp vps:/srv/easyappointments/backups/* ~/backups/easyappointments/
```

To restore: `docker compose down`, `sudo rm -rf /srv/easyappointments/mysql`, recreate that
directory as in step 2, untar the config archive back into /srv/easyappointments so .env is in
place before anything starts, `docker compose up -d easyappointments-db`, wait a minute for it
to report healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T easyappointments-db sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" easyappointments'`,
then `docker compose up -d`. Tell the user what is at stake: the diary, the customer list and
every future appointment are in that one dump, and a business whose bookings vanish hears about
it from the customers at the door.

## 9. Updating later

New versions are listed at https://github.com/alextselegidis/easyappointments/releases, and the
image tag matching each one is published on Docker Hub. Take both backup artifacts first, then
edit the image line in /srv/easyappointments/compose.yml to the new tag and its digest:

```bash
cd /srv/easyappointments
docker compose pull
docker compose up -d
docker compose logs --tail 30 easyappointments
```

The application migrates its own database on the way up, so watch that log until it settles,
then re-run the three asserts from step 7. An update logs everyone out, because sessions are
files inside the container.

## 10. What will probably go wrong

The first thing I did after the installer finished was book myself an appointment on the public
page, and the confirmation screen said `An email with the appointment details has been sent to
you.` Nothing had been sent and nothing would be: this install configures no SMTP, and the
failure is caught and written to a log inside the container rather than shown. The appointment
was in the calendar the whole time, which is what matters. Do not read that screen as
proof mail works. Tell the user that if they are not adding a relay today they should turn
`Customer Notifications` off under Settings, so the confirmation stops promising customers
something this server cannot do.

## 11. Out of scope

- Do not configure SMTP. The image takes `MAIL_SMTP_HOST` and its siblings as environment
  variables, and choosing a relay, writing SPF and DKIM records and owning deliverability is a
  separate evening.
- Do not enable Google Calendar sync. It needs an OAuth client registered in the user's own
  Google Cloud project, and `GOOGLE_SYNC_FEATURE` stays `FALSE` here.
- Do not set up LDAP or CalDAV sync. Upstream's development stack ships OpenLDAP and Baikal for
  testing those, and neither belongs here.
- Do not install phpMyAdmin. If the database needs a query, run the client inside the container
  that already has one.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Easy!Appointments 1.6.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. `<DOMAIN>` becomes `BASE_URL`, the address printed inside every
booking, reschedule and cancellation link this software sends out. Change it later and the links
your customers are holding stop working. Pick the hostname you intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `1536` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname that
does not resolve and failed attempts count against a rate limit you cannot see. Under 1536 MB
available is the one to take seriously here: MySQL 8.4 plus PHP on a 1 GB box will install fine
and then get killed by the kernel during the first busy morning.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/easyappointments /srv/easyappointments/backups
sudo install -d -m 700 /srv/easyappointments/mysql
ls -la /srv/easyappointments
```

You should see: `backups` owned by you, and `mysql` at mode `drwx------` owned by root.

If you do not: leave `mysql` owned by root on purpose. The MySQL image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it
refuse. There is no `data` directory for the application itself, and that is not an omission:
every appointment, customer, service, provider and working plan, and the company logo too, is a
row in MySQL. The container's own storage folder holds sessions, cache and logs, and losing it
logs everyone out and costs nothing else.

## 3. Secrets

Two secrets, both database passwords: one for the MySQL root account, one for the account the
application connects with. Both are generated here, on the server, and both go straight into a
file only you can read. Replace `<DOMAIN>` on the first line before you paste.

```bash
umask 077
cat > /srv/easyappointments/.env <<EOF
BASE_URL=https://<DOMAIN>
DB_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/easyappointments/.env
umask 022
ls -l /srv/easyappointments/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/easyappointments/.env`
and carry on. If the file already existed from an earlier attempt, this block has now
overwritten both passwords, which is harmless before the database exists and a problem
afterwards: MySQL keeps the password it was created with, so a changed `DB_PASSWORD` on an
existing data directory shows up as an access-denied error in the application log rather than as
anything about passwords.

Do not paste that file, either password, or any command output containing them into this chat
window. Nothing in this install ever needs you to.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/easyappointments/compose.yml <<'EOF'
# Easy!Appointments · the deterministic fallback. Authored by caniselfhostit
# from the upstream documentation, not copied from a repository:
#   server image ... https://github.com/alextselegidis/easyappointments-docker/blob/master/README.md
#   entrypoint ..... https://github.com/alextselegidis/easyappointments-docker/blob/master/assets/docker-entrypoint.sh
#   install guide .. https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/installation-guide.md
#
# Two services: the PHP application and the MySQL holding every appointment,
# customer, service and setting. Upstream's own docker-compose.yml adds
# phpMyAdmin, Mailpit, Baikal and OpenLDAP, and their docs call that stack
# development only, so this file runs the image the same author publishes for
# servers. The app container gets no volume: its entrypoint rewrites config.php
# from these variables at every start. Digests read from Docker Hub on
# 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  easyappointments-db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: easyappointments
      MYSQL_USER: easyappointments
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    volumes:
      - /srv/easyappointments/mysql:/var/lib/mysql
    healthcheck:
      # -h 127.0.0.1 forces TCP: the first start runs a socket-only server.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
      interval: 10s
      start_period: 60s
      timeout: 5s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other container.

  easyappointments:
    image: alextselegidis/easyappointments:1.6.0@sha256:ab35b8872d5d3328fa3afb641a89a75f3c6f96f3fb98d6d6d3447fff9d357fa1
    restart: unless-stopped
    environment:
      # BASE_URL is the address printed inside every booking, reschedule and
      # cancel link, so it carries the https Caddy terminates out front.
      BASE_URL: ${BASE_URL}
      DEBUG_MODE: "FALSE"
      DB_HOST: easyappointments-db
      DB_NAME: easyappointments
      DB_USERNAME: easyappointments
      DB_PASSWORD: ${DB_PASSWORD}
      # Off: turning it on means an OAuth client in your own Google project.
      GOOGLE_SYNC_FEATURE: "FALSE"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8160.
      - "127.0.0.1:8160:80"
    depends_on:
      easyappointments-db:
        condition: service_healthy
EOF
cd /srv/easyappointments && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/easyappointments/compose.yml` and paste again in one go. A warning
about the `DB_PASSWORD` variable not being set means step 3 did not write the file, or you are
running the command from a different directory: compose reads `.env` from the folder the file
sits in.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-easyappointments
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Easy!Appointments · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/alextselegidis/easyappointments-docker/blob/master/README.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASE_URL in .env, so the two have to agree.

<DOMAIN> {
	# The booking page is HTML, CSS and JavaScript, so compression pays here.
	encode zstd gzip

	header {
		# Booking page and backend share one hostname, so a downgrade on
		# either is a downgrade on both. The application sends its own
		# X-Frame-Options: SAMEORIGIN, not repeated here.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8160 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy terminates TLS
	# and speaks plain http here, which is why BASE_URL carries the https:
	# PHP reads it to mark the session cookie secure.
	reverse_proxy 127.0.0.1:8160
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-easyappointments /etc/caddy/Caddyfile`,
reload, and paste again. Caddy terminates TLS and speaks plain http to the container, which is
why `BASE_URL` in your .env carries the https. PHP reads that value to decide the session cookie
is a secure one, so an http `BASE_URL` behind an https site is a real weakness rather than a
cosmetic mismatch.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8160` or `3306`.

If you do not: delete anything for `8160` or `3306` with `sudo ufw delete allow 8160`. 8160 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp redirects to HTTPS and answers the ACME
challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

The first start initialises MySQL from nothing, which takes a minute or two, and the application
container is held back until the database reports healthy. The loop below is that wait.

```bash
cd /srv/easyappointments
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/index.php/installation); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/index.php/installation | grep -qF 'Easy!Appointments Installation' && echo "installer OK" || echo "installer MISSING"
```

You should see: the loop counting up and reaching `200`, then `installer OK`.

If you do not: run `docker compose logs --tail 20 easyappointments-db` first, because a database
that never reports healthy holds everything else back, then
`docker compose logs --tail 40 easyappointments`. A `404` where a `200` was expected means Caddy
is not reaching the container: check `docker compose ps`. A `502` usually means the loop ran out
before MySQL finished; give it another two minutes and run the loop again.

Now open https://<DOMAIN>/index.php/installation in a browser. The page shows the heading
`Easy!Appointments Installation` over the line
`Welcome to the Easy!Appointments installation page.` It is an open form on a public hostname
and it hands the administrator account to whoever loads it first, so do this now rather than
tomorrow: enter your first name, last name, email, a username and a password of at least 8
characters, add your company name and company email, and press Install.

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/index.php/installation
curl -sS https://<DOMAIN>/ | grep -qF 'Book Appointment With' && echo "booking page OK" || echo "booking page MISSING"
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/index.php/api/v1/appointments
```

You should see: `307` (any other 3xx means the same thing), then `booking page OK`, then `401`.

If you do not: a `200` from the first line means the installer form is still there and your
account was never created, so go back to the browser and finish it before anything else. The
`307` is this framework's answer to a redirected GET, and here it means the installer is
redirecting away because the `users` table now exists; a `302` from a different proxy setup
means exactly the same. The `401` on the last line is the API
refusing a caller with no credentials, and it is the security check in this step: anything else
there, especially a `200`, means stop and investigate. A running container is not success.

The backend where you set your working hours, services and providers is
https://<DOMAIN>/index.php/calendar, and it asks for the username and password you entered in
the installer.

## 8. First backup and restore

Two artifacts. The database holds every appointment, customer and setting. The config archive
holds the files that rebuild the service around it.

```bash
cd /srv/easyappointments
docker compose exec -T easyappointments-db sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction easyappointments' | gzip > /srv/easyappointments/backups/easyappointments-db-$(date +%F).sql.gz
sudo tar -czf /srv/easyappointments/backups/easyappointments-config-$(date +%F).tar.gz -C /srv/easyappointments compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/easyappointments/backups/
```

You should see: two files, the dump a few dozen kilobytes on a fresh install and the config
archive a couple of kilobytes. One warning from mysqldump about using a password on the command
line goes to stderr and is expected. Nothing goes offline: `--single-transaction` snapshots a
running InnoDB database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means mysqldump failed and
the shell created the file anyway. Run the same line without `| gzip` to read the error. The
password is read from inside the container, which is why it does not appear in your shell
history or in `ps`.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/easyappointments
scp vps:/srv/easyappointments/backups/* ~/backups/easyappointments/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/easyappointments/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty diary:

```bash
cd /srv/easyappointments
docker compose down
sudo rm -rf /srv/easyappointments/mysql
sudo install -d -m 700 /srv/easyappointments/mysql
docker compose up -d easyappointments-db
sleep 90
gunzip -c /srv/easyappointments/backups/easyappointments-db-$(date +%F).sql.gz | docker compose exec -T easyappointments-db sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" easyappointments'
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/ | grep -qF 'Book Appointment With' && echo "restore OK" || echo "restore MISSING"
```

You should see: `restore OK`, which means the booking page came back from a database that was
deleted and rebuilt from the dump.

If you do not: `ERROR 1045 (28000): Access denied` means the new data directory was initialised
with a different password, so check that .env is the same file it was when you took the dump.
`Unknown database` means the container had not finished initialising when the pipe ran, so wait
another minute and run the `gunzip` line again. Understand the stakes before you skip this step:
your diary, your customer list and every appointment anyone has booked are in that one file.

## 9. Updating later

New versions are listed at https://github.com/alextselegidis/easyappointments/releases, and the
matching image tag is published on Docker Hub. Take both backup artifacts first, then edit the
`image:` line in /srv/easyappointments/compose.yml to the new tag and its digest.

```bash
cd /srv/easyappointments
docker compose pull
docker compose up -d
docker compose logs --tail 30 easyappointments
```

You should see: the application starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. The application
migrates its own database on the way up, so re-run the three checks from step 7 before you call
the update done. Everyone is logged out by an update, because sessions are files inside the
container, and that is expected rather than a sign the upgrade broke your accounts.

## 10. What will probably go wrong

The first thing I did after the installer finished was book myself an appointment on the public
page, and the confirmation screen said `An email with the appointment details has been sent to
you.` Nothing had been sent and nothing would be: this install configures no SMTP, and the
failure is caught and written to a log inside the container rather than shown. The appointment
was in the calendar the whole time, which is what matters. Do not read that screen as proof mail
works. If you are not adding a mail relay today, turn `Customer Notifications` off under
Settings, so the confirmation stops promising your customers something your server cannot do.

## 11. Out of scope

- Do not configure SMTP. The image takes `MAIL_SMTP_HOST` and its siblings as environment
  variables, and choosing a relay, writing SPF and DKIM records and owning deliverability is a
  separate evening.
- Do not enable Google Calendar sync. It needs an OAuth client registered in your own Google
  Cloud project, and `GOOGLE_SYNC_FEATURE` stays `FALSE` here.
- Do not set up LDAP or CalDAV sync. Upstream's development stack ships OpenLDAP and Baikal for
  testing those, and neither belongs here.
- Do not install phpMyAdmin. If the database needs a query, run the client inside the container
  that already has one.

306 lines · 14,881 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Easy!Appointments 1.6.0, with the MySQL it keeps every appointment in, under
~/selfhost/easyappointments, answering at http://localhost:8160.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all. The
booking page lives at http://localhost:8160, which means "this computer" wherever it is read, so
a link sent to a customer, or opened on the user's own phone, resolves to nothing. They get the
diary, the services and the working plans, driven from this desk, not a page customers can book
on.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. This install needs 1536 MB of RAM available
and 5 GB free on the home disk, most of it for MySQL 8.4; both images publish amd64 and arm64.
On macOS and Windows the figure printed is the host's, and Docker Desktop takes its share out of
it. If either floor is missed, print both numbers and stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/easyappointments/backups
ls -la ~/selfhost/easyappointments
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder, deliberately:
every appointment, customer and setting is a row in MySQL, which step 5 keeps in a volume Docker
manages.

## 4. Secrets

Two secrets, both database passwords: the MySQL root account and the account the application
connects with. Generate both here, print neither, and keep both out of your summary and out of
any log line.

```bash
umask 077
cat > ~/selfhost/easyappointments/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/easyappointments/.env
umask 022
ls -l ~/selfhost/easyappointments/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems, though on Windows the mode bits are advisory and the real boundary is
the user's own account. No administrator password is generated: that one is created in a browser
in step 7, by the user.

## 5. compose.yml

```bash
cat > ~/selfhost/easyappointments/compose.yml <<'EOF'
# Easy!Appointments · the local-path fallback, authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   server image ... https://github.com/alextselegidis/easyappointments-docker/blob/master/README.md
#   entrypoint ..... https://github.com/alextselegidis/easyappointments-docker/blob/master/assets/docker-entrypoint.sh
#   install guide .. https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/installation-guide.md
#
# Two services, driven from ~/selfhost/easyappointments/ so one file works on
# macOS, Linux and Windows. The database is a named volume, not a bind mount:
# the MySQL image chowns /var/lib/mysql to a uid Docker Desktop's Windows file
# sharing cannot grant on a home-directory bind mount. The app container needs
# none, since every appointment is a row in MySQL. Digests read from Docker Hub
# on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  easyappointments-db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: easyappointments
      MYSQL_USER: easyappointments
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    volumes:
      - easyappointments-db:/var/lib/mysql
    healthcheck:
      # -h 127.0.0.1 forces TCP: the first start runs a socket-only server.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
      interval: 10s
      start_period: 60s
      timeout: 5s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other container.

  easyappointments:
    image: alextselegidis/easyappointments:1.6.0@sha256:ab35b8872d5d3328fa3afb641a89a75f3c6f96f3fb98d6d6d3447fff9d357fa1
    restart: unless-stopped
    environment:
      # BASE_URL is inside every booking link, so they work here only.
      BASE_URL: http://localhost:8160
      DEBUG_MODE: "FALSE"
      DB_HOST: easyappointments-db
      DB_NAME: easyappointments
      DB_USERNAME: easyappointments
      DB_PASSWORD: ${DB_PASSWORD}
      # Off: turning it on means an OAuth client in your own Google project.
      GOOGLE_SYNC_FEATURE: "FALSE"
    ports:
      # Loopback only: no other device on the wifi can reach 8160.
      - "127.0.0.1:8160:80"
    depends_on:
      easyappointments-db:
        condition: service_healthy

volumes:
  easyappointments-db:
EOF
cd ~/selfhost/easyappointments && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one published port, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve, and no certificate because a certificate attests a public name and nothing here has
one; browsers treat http://localhost as a secure context anyway, so pages needing crypto still
work. 8160 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the
same wifi, not anyone on the internet. For a booking page that is the whole trade. Confirm it:

```bash
grep -n '"127.0.0.1:' ~/selfhost/easyappointments/compose.yml
```

Assert: exactly one line, `- "127.0.0.1:8160:80"`. MySQL publishes no host port, so 3306 cannot
appear.

## 7. Start and verify

MySQL initialises from nothing on the first start, and the app container is held back until it
reports healthy.

```bash
cd ~/selfhost/easyappointments
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8160/index.php/installation); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8160/index.php/installation | grep -qF 'Easy!Appointments Installation' && echo "installer OK" || echo "installer MISSING"
```

Assert both and print what you received. The loop ends printing `200`, and the second command
prints `installer OK`: that page carries the heading `Easy!Appointments Installation`. If either
misses, stop, run `docker compose logs --tail 40 easyappointments` and
`docker compose logs --tail 20 easyappointments-db`, and name the likely cause: a database that
never reports healthy points at step 4. If `port is already allocated` came back, something else
holds 8160; stop until the user frees it, because 8160 is inside `BASE_URL` and every link.

STOP: tell the user to open http://localhost:8160/index.php/installation, enter their name,
email, a username and a password of at least 8 characters, add their company name and company
email, and press Install. Wait. Do not continue until they confirm.

Once they confirm:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8160/index.php/installation
curl -sS http://localhost:8160/ | grep -qF 'Book Appointment With' && echo "booking page OK" || echo "booking page MISSING"
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8160/index.php/api/v1/appointments
```

Assert all three: `307` from the first, or any other 3xx, so the installer now redirects away
because the `users` table exists and the form is gone; `booking page OK` from the second, the
page carrying the title `Book Appointment With`; `401` from the third, the API refusing a caller
with no credentials. A running container is not success.

## 8. First backup and restore

Two artifacts: a database dump with every appointment and customer, and a config archive with
the two files that rebuild the service around it.

```bash
cd ~/selfhost/easyappointments
docker compose exec -T easyappointments-db sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction easyappointments' | gzip > ~/selfhost/easyappointments/backups/easyappointments-db-$(date +%F).sql.gz
tar -C ~/selfhost/easyappointments -czf ~/selfhost/easyappointments/backups/easyappointments-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/easyappointments/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. The password is read inside
the container from its own environment, so it never reaches this machine's process list.
mysqldump still warns about command-line passwords on stderr, which is expected, and nothing is
stopped: `--single-transaction` snapshots a running InnoDB database consistently.

Both archives sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a folder their sync service
watches or a USB stick, and copy both there with `cp`. Assert: the user confirms both filenames
are there. If they have neither, say plainly that this install has no backup.

To restore, in this order: untar the config archive into ~/selfhost/easyappointments first, so
compose.yml and .env are back before any container starts, because MySQL reads `DB_PASSWORD`
from .env the moment it initialises an empty volume; `docker compose down -v`, the one place
`-v` belongs, because it drops the old volume on purpose; `docker compose up -d
easyappointments-db`; wait a minute for healthy; pipe `gunzip -c` on the dump into
`docker compose exec -T easyappointments-db sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" easyappointments'`;
`docker compose up -d`. Then open the calendar and check an appointment you recognise is there.

## 9. Updating later

New versions are listed at https://github.com/alextselegidis/easyappointments/releases, with the
matching image tag on Docker Hub. Take both backups first, then edit the image line in
~/selfhost/easyappointments/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/easyappointments
docker compose pull
docker compose up -d
docker compose logs --tail 30 easyappointments
```

The application migrates its own database on the way up, so watch that log until it settles,
then re-run step 7's three asserts. An update logs everyone out: sessions are files in the
container.

## 10. What will probably go wrong

I opened http://localhost:8160 about twenty seconds after `docker compose up -d` and the browser
said the connection was refused, which reads like an install that failed silently. Nothing had
failed. The app container is held back until MySQL reports healthy, and MySQL's first start
builds its data directory from scratch, which took a little over two minutes here. The loop in
step 7 exists for that wait. `docker compose ps` showing the app as `Created` rather than `Up`
means it is still waiting on the database, so do not restart anything.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `BASE_URL` to this machine's LAN address and do not rebind 8160 to 0.0.0.0 so a
  phone can reach it. That puts a booking form and a login page on every network the user joins.
- Do not configure SMTP. A laptop is the worst place to start owning deliverability from.
- Do not enable Google Calendar sync. It needs an OAuth client in the user's own Google Cloud
  project, and `GOOGLE_SYNC_FEATURE` stays `FALSE` here.
- Do not install phpMyAdmin. If the database needs a query, run the client in the container.
compose.local.ymlthe services, pinned · local layout57 lines

authored from upstream docs, never pasted · 2,482 bytes

# Easy!Appointments · the local-path fallback, authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   server image ... https://github.com/alextselegidis/easyappointments-docker/blob/master/README.md
#   entrypoint ..... https://github.com/alextselegidis/easyappointments-docker/blob/master/assets/docker-entrypoint.sh
#   install guide .. https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/installation-guide.md
#
# Two services, driven from ~/selfhost/easyappointments/ so one file works on
# macOS, Linux and Windows. The database is a named volume, not a bind mount:
# the MySQL image chowns /var/lib/mysql to a uid Docker Desktop's Windows file
# sharing cannot grant on a home-directory bind mount. The app container needs
# none, since every appointment is a row in MySQL. Digests read from Docker Hub
# on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  easyappointments-db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: easyappointments
      MYSQL_USER: easyappointments
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    volumes:
      - easyappointments-db:/var/lib/mysql
    healthcheck:
      # -h 127.0.0.1 forces TCP: the first start runs a socket-only server.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
      interval: 10s
      start_period: 60s
      timeout: 5s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other container.

  easyappointments:
    image: alextselegidis/easyappointments:1.6.0@sha256:ab35b8872d5d3328fa3afb641a89a75f3c6f96f3fb98d6d6d3447fff9d357fa1
    restart: unless-stopped
    environment:
      # BASE_URL is inside every booking link, so they work here only.
      BASE_URL: http://localhost:8160
      DEBUG_MODE: "FALSE"
      DB_HOST: easyappointments-db
      DB_NAME: easyappointments
      DB_USERNAME: easyappointments
      DB_PASSWORD: ${DB_PASSWORD}
      # Off: turning it on means an OAuth client in your own Google project.
      GOOGLE_SYNC_FEATURE: "FALSE"
    ports:
      # Loopback only: no other device on the wifi can reach 8160.
      - "127.0.0.1:8160:80"
    depends_on:
      easyappointments-db:
        condition: service_healthy

volumes:
  easyappointments-db:

agent-readable mirror: /self-host/acuity-scheduling.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned56 lines

authored from upstream docs, never pasted · 2,602 bytes

# Easy!Appointments · the deterministic fallback. Authored by caniselfhostit
# from the upstream documentation, not copied from a repository:
#   server image ... https://github.com/alextselegidis/easyappointments-docker/blob/master/README.md
#   entrypoint ..... https://github.com/alextselegidis/easyappointments-docker/blob/master/assets/docker-entrypoint.sh
#   install guide .. https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/installation-guide.md
#
# Two services: the PHP application and the MySQL holding every appointment,
# customer, service and setting. Upstream's own docker-compose.yml adds
# phpMyAdmin, Mailpit, Baikal and OpenLDAP, and their docs call that stack
# development only, so this file runs the image the same author publishes for
# servers. The app container gets no volume: its entrypoint rewrites config.php
# from these variables at every start. Digests read from Docker Hub on
# 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  easyappointments-db:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: easyappointments
      MYSQL_USER: easyappointments
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    volumes:
      - /srv/easyappointments/mysql:/var/lib/mysql
    healthcheck:
      # -h 127.0.0.1 forces TCP: the first start runs a socket-only server.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"]
      interval: 10s
      start_period: 60s
      timeout: 5s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other container.

  easyappointments:
    image: alextselegidis/easyappointments:1.6.0@sha256:ab35b8872d5d3328fa3afb641a89a75f3c6f96f3fb98d6d6d3447fff9d357fa1
    restart: unless-stopped
    environment:
      # BASE_URL is the address printed inside every booking, reschedule and
      # cancel link, so it carries the https Caddy terminates out front.
      BASE_URL: ${BASE_URL}
      DEBUG_MODE: "FALSE"
      DB_HOST: easyappointments-db
      DB_NAME: easyappointments
      DB_USERNAME: easyappointments
      DB_PASSWORD: ${DB_PASSWORD}
      # Off: turning it on means an OAuth client in your own Google project.
      GOOGLE_SYNC_FEATURE: "FALSE"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8160.
      - "127.0.0.1:8160:80"
    depends_on:
      easyappointments-db:
        condition: service_healthy
Caddyfilethe hostname and TLS30 lines

authored from upstream docs, never pasted · 1,210 bytes

# Easy!Appointments · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/alextselegidis/easyappointments-docker/blob/master/README.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also BASE_URL in .env, so the two have to agree.

<DOMAIN> {
	# The booking page is HTML, CSS and JavaScript, so compression pays here.
	encode zstd gzip

	header {
		# Booking page and backend share one hostname, so a downgrade on
		# either is a downgrade on both. The application sends its own
		# X-Frame-Options: SAMEORIGIN, not repeated here.
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8160 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy terminates TLS
	# and speaks plain http here, which is why BASE_URL carries the https:
	# PHP reads it to mark the session cookie secure.
	reverse_proxy 127.0.0.1:8160
}
install.shthe same install, no agent187 lines

authored from upstream docs, never pasted · 8,560 bytes

#!/usr/bin/env bash
# Easy!Appointments · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=book.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/alextselegidis/easyappointments-docker/blob/master/README.md
#   https://github.com/alextselegidis/easyappointments-docker/blob/master/assets/docker-entrypoint.sh
#   https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/installation-guide.md
#   https://caddyserver.com/docs/automatic-https
#
# Two secrets are generated here, on this machine: the MySQL root password and
# the password the application connects with. Both go into
# /srv/easyappointments/.env with mode 600 and neither is ever printed.
#
# There is one thing this script cannot do for you. The administrator account is
# created in a browser, on the installation page, with a password you choose.
# The script opens that page, waits for you to finish, and only then verifies.
#
# DOMAIN_HOST is also BASE_URL, the address printed inside every booking link.
# Choose it once. Changing it later breaks links your customers already hold.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/easyappointments}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. book.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1536 ] || die "only ${avail_mb} MB of RAM available; PHP plus MySQL 8.4 wants 1536 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# No directory for the application itself: every appointment, customer, service
# and setting is a row in MySQL, and the container's storage folder holds only
# sessions, cache and logs. The MySQL directory stays root-owned, because the
# image chowns it to its own uid on first start.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/mysql"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64: both travel inside a connection string, and neither
# wants escaping. Read them later with
#   sudo grep -E 'DB_PASSWORD|MYSQL_ROOT_PASSWORD' /srv/easyappointments/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		BASE_URL=https://${DOMAIN_HOST}
		DB_PASSWORD=$(openssl rand -hex 32)
		MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-easyappointments"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8160 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8160 and 3306 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# MySQL initialises from nothing on the first start, which takes a minute or
# two, and compose holds the application container back until it reports
# healthy.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/index.php/installation"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/index.php/installation" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "the installation page answered ${code:-nothing}. Check: docker compose logs --tail 40 easyappointments"

curl -sS "https://${DOMAIN_HOST}/index.php/installation" | grep -qF 'Easy!Appointments Installation' \
	|| die "that page answered 200 without the installation heading. Check: docker compose logs --tail 40 easyappointments"

# --- 7. The one step only a human can do -------------------------------------
#
# That page is an open form on a public hostname and it hands the administrator
# account to whoever loads it first, so it is closed by being used, now.

cat <<-OPEN

	==> Open this page in a browser and create the administrator account:

	      https://${DOMAIN_HOST}/index.php/installation

	    Your name, email, a username, a password of at least 8 characters,
	    your company name and company email. Then press Install.

	    This script is waiting, and gives up after 20 minutes.

OPEN

# A redirect means the users table now exists, so the form has closed itself.
# CodeIgniter answers a redirected GET with 307; accept any 3xx.
for _ in $(seq 1 120); do
	installed="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/index.php/installation" || true)"
	case "$installed" in 3??) break ;; esac
	sleep 10
done
case "${installed:-}" in
	3??) ;;
	*) die "the installation page still answers ${installed:-nothing}, so no account was created. It is open to the internet until it is used." ;;
esac

curl -sS "https://${DOMAIN_HOST}/" | grep -qF 'Book Appointment With' \
	|| die "the booking page did not carry its title. Check: docker compose logs --tail 40 easyappointments"

# The API must refuse an unauthenticated call. Upstream answers 401 to a caller
# with no bearer token and no basic-auth credentials.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/index.php/api/v1/appointments" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and investigate."

# --- 8. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T easyappointments-db sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction easyappointments' \
	| gzip > "$APP_DIR/backups/easyappointments-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/easyappointments-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/easyappointments-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Easy!Appointments is answering at https://${DOMAIN_HOST}/

	  1. The public booking page is https://${DOMAIN_HOST}/ and your backend is
	     https://${DOMAIN_HOST}/index.php/calendar, with the username and
	     password you entered on the installation page. That password was
	     yours to choose and this script never saw it.
	  2. Set your working hours under Settings, then add your services and
	     your providers. The installer left one sample service and one sample
	     provider behind for you to edit or delete.
	  3. No mail is configured. A customer who books will see a screen saying
	     the details were emailed to them, and no email will arrive. Turn
	     Customer Notifications off under Settings until you add a relay.
	  4. First backup written to $APP_DIR/backups: a database dump and a
	     config archive. They are on the same disk as the data, which is not a
	     backup. Copy them somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Acuity Scheduling.

  • Mail is not configured, and the software assumes it is. A customer who books sees a screen reading `An email with the appointment details has been sent to you.` and no email arrives, because there is no relay here. The appointment itself is real and it is in your calendar. Turn Customer Notifications off under Settings until you add a relay, or add one and take on deliverability.
  • You own a MySQL. Every appointment, customer, service, provider, working plan and setting is a row in it, the company logo included, so the dump is the whole business and the containers are disposable. Nothing else on disk matters.
  • The installation page is open until you use it. Between the first container start and the moment you create the administrator, anyone who loads your hostname can create it instead. Do it in the first minute, not the next morning, and check afterwards that the page has stopped answering.
  • Google Calendar sync is a separate afternoon. Easy!Appointments can push appointments to Google, but that means registering an OAuth client in your own Google Cloud project and pasting its credentials in. Until you do, this diary is the only diary, and catching a clash with your work calendar is your job.
  • No payment collection, no text reminders, no HIPAA agreement, no branding removal. Those are most of what the paid tiers are actually selling on top of a booking page, and none of them is here.

Where this came from

“This Docker setup is for development only. Don't use it in production. For a production Docker image, see: https://github.com/alextselegidis/easyappointments-docker”

  • The repository's own docker-compose.yml is a development environment that also runs phpMyAdmin, Mailpit, Baikal and OpenLDAP, and upstream's docs say to use the separately published server image instead. source
  • The published image is built on php:8.2-apache, exposes port 80, and carries default values of root and secret for the database account, which is why this install overrides every one of them. source
  • The image's entrypoint rewrites config.php and the mail configuration from environment variables at every container start, and appends BASE_URL to the CodeIgniter config as the last assignment in the file. source
  • Until the installer has been run, the base URL redirects to the installation page, which is an open form that creates the administrator account, seeds one sample service and one sample provider, and cannot be reached again afterwards. source
  • Appointment notification mail is sent inside a try/catch that logs the exception, after the appointment has already been saved, so a booking completes and appears in the calendar on a server with no SMTP configured. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Acuity Scheduling?

    Not Acuity Scheduling itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Easy!Appointments. A front-desk diary on your own domain: services, providers, working hours, and customers who book without creating an account. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Acuity Scheduling?

    Easy!Appointments. A front-desk diary on your own domain: services, providers, working hours, and customers who book without creating an account. The same shape of product rather than a smaller cousin: services with a duration and a price, providers with their own working plans, a public booking page a customer uses without creating an account, and a calendar the front desk actually watches. The install is modest by this catalogue's standards, two containers and a MySQL, and the honest cost is what it does not do. There are no payments, no text reminders, and no email at all until you wire in a relay, which matters more here than it sounds: the confirmation screen tells your customer their details were emailed to them. Also evaluated and ranked below it: Cal.com. The prompt on this page installs Easy!Appointments only. Easy!Appointments is GPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Acuity Scheduling?

    1536 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Easy!Appointments itself is free and GPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Acuity Scheduling Standard, $34/mo — $408 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Easy!Appointments install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Easy!Appointments on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Easy!Appointments on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Only this computer can open http://localhost:8160, so what you get is a diary you drive yourself rather than a booking page you can send a customer to. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.