Can I self-host Calendly?

YES, BUT · ONE WEEKEND— setup effort 3 of 4

YES, BUT — it's called Cal.com. It takes one prompt, a 4096 MB VPS, and about 300 minutes. That is $12 a month you stop paying Calendly — $144 a year on the Standard plan, 1 seat assumed.

Why people pay for Calendly

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.

Calendly sells the end of the scheduling email thread, and it works because the other person never has to sign up for anything. What you are renting is a link that stays alive, a page that renders correctly on a stranger's phone, and connectors into Google, Outlook, Zoom and Salesforce that keep working after those vendors change something. The free tier deliberately stops at one event type, which is exactly where a working habit starts costing money.

Calendly plans and list prices
PlanList priceWhat it buys
FreefreeOne event type, one calendar connection. Advertised as always free.
Standardthe plan this page prices against$12/mo per seat$10 per seat per month on annual billing, which is the figure the pricing page shows by default under a Save 16% label.
Teams$20/mo per seat$16 per seat per month on annual billing, shown under a Save 20% label. This is the tier that adds round robin and shared team pages.
Enterprisequote onlyThe pricing page states it starts at $15,000 a year and sends you to sales for a number.

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

Replaced by Cal.com

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

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 only one here that is the same product rather than a smaller cousin: event types with buffers and minimum notice, a public booking page, calendar sync, webhooks and an API. The costs are honest ones. It is the heaviest install in this category, wanting 4 GB and a PostgreSQL, its first boot genuinely looks broken for ten minutes, and the teams and routing features that justify Calendly's Teams tier sit behind a commercial licence key you will not have. Take it if you want your own booking domain and one person's calendar; look elsewhere if you came for round robin.

The swap

You're paying

Calendly

$12/mo · $144/yr

is replaced by

You'd run

Cal.com

ONE WEEKEND · ~300 min to running · 4096 MB RAM

Calendly Standard · 1 seat assumed · vendor list price · checked 2026-08-05 · source · confidence: medium

Before you start

RAM floor
4096 MBfloor from upstream docs — not measured by us yet
Disk
15 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
~300 min3–24 hours, through the first backup

The prompt

Two paths to the same Cal.com: 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

339 lines · 14,901 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 Cal.com 6.2.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: the hostname becomes
`NEXT_PUBLIC_WEBAPP_URL`, the container rewrites its own compiled-in address to match it, and
every booking link the user hands out carries it.

Cal.com needs 4096 MB of RAM available and 15 GB free on /srv: the image is 1.5 GB compressed,
before PostgreSQL. Measure four things:

```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 4096 MB or free disk is under 15 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop. The architecture
decides one line in step 4: upstream ships no multi-architecture manifest, so amd64 and arm64
are separate tags.

## 2. Layout

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

Assert: `ls -la` shows `backups` owned by the login user and `postgres` at mode `700` owned by
root. The PostgreSQL image chowns its own data directory on first start, so leave that alone.
Cal.com keeps nothing else on disk: bookings, avatars and calendar credentials are database
rows.

## 3. Secrets

Three secrets: the PostgreSQL password, the NextAuth session secret, and the encryption key over
saved calendar credentials. Generate all three on the server. Do not print any of them, do not
repeat them in your summary, and do not put them in any log line. Upstream documents
`openssl rand -base64 32` for the session secret and `openssl rand -base64 24` for the
encryption key, the 32-character key AES-256 wants; the database password is hex, so nothing in
the connection string needs escaping.

```bash
umask 077
cat > /srv/calcom/.env <<EOF
NEXT_PUBLIC_WEBAPP_URL=https://<DOMAIN>
NEXT_PUBLIC_WEBSITE_URL=https://<DOMAIN>
CALCOM_TELEMETRY_DISABLED=1
POSTGRES_PASSWORD=$(openssl rand -hex 32)
NEXTAUTH_SECRET=$(openssl rand -base64 32)
CALENDSO_ENCRYPTION_KEY=$(openssl rand -base64 24)
EMAIL_FROM=CHANGE_ME
EMAIL_FROM_NAME=CHANGE_ME
EMAIL_SERVER_HOST=CHANGE_ME
EMAIL_SERVER_PORT=587
EMAIL_SERVER_USER=CHANGE_ME
EMAIL_SERVER_PASSWORD=CHANGE_ME
EOF
chmod 600 /srv/calcom/.env
umask 022
ls -l /srv/calcom/.env
```

Assert: the file exists with mode `-rw-------`. Tell the user `CALENDSO_ENCRYPTION_KEY` is the
one value they cannot regenerate: every Google or Outlook connection they later add is encrypted
with it, and replacing it turns those rows into noise. The five `CHANGE_ME` lines are the
outbound relay, and Cal.com mails the confirmation to whoever booked, so it is not optional.

STOP: tell the user to open `nano /srv/calcom/.env`, replace every `CHANGE_ME` with the matching
value from their mail relay, correct `EMAIL_SERVER_PORT` if it is not 587, save, and confirm. Do
not continue until they do, and do not ask them to paste those values to you.

```bash
grep -c CHANGE_ME /srv/calcom/.env || true
```

Assert: that prints `0`. It counts lines, never values.

## 4. compose.yml

```bash
cat > /srv/calcom/compose.yml <<'EOF'
# Cal.com · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository. Read at tag v6.2.0 of
# https://github.com/calcom/cal.diy : docs/self-hosting/docker.mdx, .env.example,
# Dockerfile and scripts/start.sh.
#
# Two services: the Cal.com web app and the PostgreSQL holding every event type,
# booking and calendar credential. Upstream's compose file builds from source
# beside an API container and a Prisma Studio; this one pulls the published
# image. Its entrypoint rewrites the URL baked into the build to
# NEXT_PUBLIC_WEBAPP_URL and migrates on every fresh container, so a first boot
# takes minutes and the health check below adds a start period.
#
# Digests read on 2026-08-05. The v6.2.0 tag is amd64 only; arm64 ships as the
# separate v6.2.0-arm tag, not one multi-architecture manifest.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: calcom-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: calcom
      POSTGRES_USER: calcom
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/calcom/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U calcom -d calcom"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  calcom:
    image: calcom/cal.com:v6.2.0@sha256:ace3bb1219fb7306585ab9f4d94d41af7ee064c343db0498173436bbe857bd49
    container_name: calcom
    restart: unless-stopped
    env_file: /srv/calcom/.env
    environment:
      # start.sh waits on this host:port pair before migrating.
      DATABASE_HOST: postgres:5432
      DATABASE_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
      # Prisma migrates over the direct URL. No pooler, so the same address.
      DATABASE_DIRECT_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://localhost:3000 || exit 1"]
      interval: 30s
      timeout: 30s
      retries: 5
      start_period: 900s
    ports:
      # Loopback only: the host's Caddy is all that reaches 8094.
      - "127.0.0.1:8094:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
```

That pins the amd64 build. If step 1 printed `arm64`, switch the image line to the release's
arm64 tag. On amd64 this changes nothing:

```bash
if [ "$(dpkg --print-architecture)" = "arm64" ]; then
  sed 's|:v6.2.0@sha256:[a-f0-9]*|:v6.2.0-arm@sha256:4b0fa72eec13bd3ddb608a6d13f05bf0ebc136e73832abfe1a8ec145db9e4651|' /srv/calcom/compose.yml > /srv/calcom/compose.arm && mv /srv/calcom/compose.arm /srv/calcom/compose.yml
fi
grep -n 'image:' /srv/calcom/compose.yml
cd /srv/calcom && docker compose config >/dev/null && echo "compose OK"
```

Assert: `grep` prints two image lines, the Cal.com one carrying `-arm` only on an arm64 host,
and the last command prints `compose OK`. No password is in this file: compose reads
`${POSTGRES_PASSWORD}` from /srv/calcom/.env to build the connection strings.

## 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 takes down every site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-calcom
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Cal.com · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/calcom/cal.diy/blob/v6.2.0/docs/self-hosting/docker.mdx
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# NEXT_PUBLIC_WEBAPP_URL in .env: the container rewrites its own built-in URL to
# match on every fresh start, so the two have to agree exactly.

<DOMAIN> {
	# Every invitee lands on a page from here, so a downgrade has a real
	# audience. No X-Frame-Options on purpose: booking pages are meant to be
	# embedded in other people's sites.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8094 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8094
}
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-calcom, reload, and report what it objected to. Caddy asks for the
certificate on the first request and renews it itself.

## 6. Firewall

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

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

80/tcp answers the ACME challenge and redirects, 443/tcp is the only way in, 443/udp is HTTP/3.
8094 stays closed because compose binds it to 127.0.0.1, and 5432 because compose never
publishes it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8094 or 5432.

## 7. Start and verify

On a fresh container the entrypoint rewrites every compiled-in copy of the built URL, waits for
PostgreSQL, migrates and seeds the app store before Next.js listens. Budget fifteen minutes and
touch nothing while the loop runs.

```bash
cd /srv/calcom
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' "https://<DOMAIN>/auth/setup?step=1"); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS "https://<DOMAIN>/auth/setup?step=1" | grep -o '<title>[^<]*</title>'
curl -sS -o /dev/null -w '%{http_code}\n' "https://<DOMAIN>/signup"
```

Assert all three, and print what you received for each. The loop ends printing `200`. The title
line reads exactly `<title>Setup | Cal.com</title>`. The last prints `200`, because registration
is open on a fresh install and the next step closes it. If any of the three misses, stop, run
`docker compose logs --tail 60 calcom` and `docker compose logs --tail 20 postgres`, and name
the likely cause: a database that never reports healthy points at step 2; a `502` after the loop
expires means the container is still migrating; `CLIENT_FETCH_ERROR` means
`NEXT_PUBLIC_WEBAPP_URL` from step 3 does not match the hostname Caddy serves. A running
container is not success.

The first screen at https://<DOMAIN>/auth/setup?step=1 is a wizard headed `Administrator user`,
with `Let's create the first administrator user.` under it.

STOP: tell the user to open https://<DOMAIN>/auth/setup?step=1, create that first account, and
wait. Do not continue until they confirm. Upstream's password rule is strict: 15 characters at
least, one number, both cases. Step 2 asks them to pick a licence, and the free AGPLv3 option is
the one this install runs under.

Once they confirm, close registration and prove it is closed:

```bash
printf 'NEXT_PUBLIC_DISABLE_SIGNUP=true\n' >> /srv/calcom/.env
cd /srv/calcom
docker compose up -d --force-recreate calcom
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' "https://<DOMAIN>/auth/login"); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' "https://<DOMAIN>/signup"
```

Assert: the last line is a 3xx status whose redirect URL contains `/auth/error`. Recreating
rewrites the built URL again, so this loop is slow too. Tell the user the `Create an account`
link stays on the login page, because it is drawn by JavaScript compiled into the image, while
the page behind it now refuses. If the assert still returns `200`, sign in and turn on
`disable-signup` at https://<DOMAIN>/settings/admin/flags.

## 8. First backup and restore

Two artifacts: the database holds every event type, booking and encrypted calendar credential;
the config archive holds what rebuilds the service around it.

```bash
cd /srv/calcom
docker compose exec -T postgres pg_dump -U calcom -d calcom | gzip > /srv/calcom/backups/calcom-db-$(date +%F).sql.gz
sudo tar -C /srv/calcom -czf /srv/calcom/backups/calcom-config-$(date +%F).tar.gz compose.yml .env
ls -lh /srv/calcom/backups/
```

Assert: both files exist and are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently. A backup on the same disk is not a backup, so run
this from the user's machine:

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

To restore: `docker compose down`, `sudo rm -rf /srv/calcom/postgres`, recreate that directory
as in step 2, untar the config archive into /srv/calcom so .env is back before anything starts,
`docker compose up -d postgres`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U calcom -d calcom`, then `docker compose up -d`. Tell
the user why the archive matters as much as the dump: `CALENDSO_ENCRYPTION_KEY` lives in .env,
the calendar credentials are encrypted with it, and a dump restored beside a new key is a table
of unreadable tokens.

## 9. Updating later

New versions are listed at https://github.com/calcom/cal.diy/releases. Take both backups first,
then edit the image line in /srv/calcom/compose.yml to the new tag and digest, keeping the
`-arm` suffix on an arm64 host:

```bash
cd /srv/calcom
docker compose pull
docker compose up -d
docker compose logs --tail 40 calcom
```

Cal.com migrates on the way up, so watch that log until it settles, then re-run step 7's health
check before calling the update done. The project renamed its repository to cal.diy after 6.2.0
and has cut no release under that name yet.

## 10. What will probably go wrong

The first boot looks like a failed install for a long time. The entrypoint rewrites every file
in the compiled Next.js output, waits for PostgreSQL, migrates, seeds the app store, and only
then does anything listen on 3000. I watched Caddy return `502` for nine minutes on a 4 GB box
and went looking for what I had broken. Nothing was. Run `docker compose logs -f calcom` and
read it instead of restarting: while `Replacing all statically built instances` or a Prisma
migration name is on screen, it is working. Restarting begins that sequence again.

## 11. Out of scope

- Do not configure Google Calendar or Outlook sync. Both need an OAuth client the user registers
  in their own Google Cloud or Azure tenant, a separate sitting.
- Do not enable organizations. `ORGANIZATIONS_ENABLED` wants a wildcard subdomain and a second
  hostname, and this install serves one domain.
- Do not add the API v2 container or Prisma Studio from upstream's compose file.
- Do not set `CALCOM_LICENSE_KEY`. That is the commercial licence for the enterprise features,
  and this install runs the free one the wizard offers in step 7.
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 Cal.com 6.2.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 `NEXT_PUBLIC_WEBAPP_URL`, and the container rewrites
the URL compiled into its own image to match it every time a fresh container starts. Every
booking link you hand out carries that hostname, so pick the one you intend to keep. Set aside
an afternoon: two of the steps below wait fifteen minutes each, and that is normal rather than a
fault.

## 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 `4096` MB available, at least `15` 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,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does not
resolve and failed attempts count against a rate limit you cannot see. Under 4096 MB of RAM is
the one to take seriously here: this is a Next.js server with a full node_modules tree beside a
PostgreSQL, and a 2 GB box will get through the migrations and then be killed during the first
real page render. Note whether the architecture line said `amd64` or `arm64`, because step 4
needs it: upstream ships no multi-architecture manifest, so the two are separate tags.

## 2. Layout

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

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

If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. There is no directory for Cal.com itself, because bookings, avatars and
calendar credentials are all rows in the database.

## 3. Secrets

Three secrets: the PostgreSQL password, the NextAuth session secret, and the encryption key that
protects saved calendar credentials. All three are generated here, on the server, and all three
go straight into a file only you can read. Upstream documents `openssl rand -base64 32` for the
session secret and `openssl rand -base64 24` for the encryption key, which is the 32-character
key AES-256 wants. Replace `<DOMAIN>` on the first two lines with your real hostname before you
paste.

```bash
umask 077
cat > /srv/calcom/.env <<EOF
NEXT_PUBLIC_WEBAPP_URL=https://<DOMAIN>
NEXT_PUBLIC_WEBSITE_URL=https://<DOMAIN>
CALCOM_TELEMETRY_DISABLED=1
POSTGRES_PASSWORD=$(openssl rand -hex 32)
NEXTAUTH_SECRET=$(openssl rand -base64 32)
CALENDSO_ENCRYPTION_KEY=$(openssl rand -base64 24)
EMAIL_FROM=CHANGE_ME
EMAIL_FROM_NAME=CHANGE_ME
EMAIL_SERVER_HOST=CHANGE_ME
EMAIL_SERVER_PORT=587
EMAIL_SERVER_USER=CHANGE_ME
EMAIL_SERVER_PASSWORD=CHANGE_ME
EOF
chmod 600 /srv/calcom/.env
umask 022
ls -l /srv/calcom/.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/calcom/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten all
three secrets, which is fine before the database exists and a problem afterwards: PostgreSQL
keeps the password it was created with, so a changed `POSTGRES_PASSWORD` against an existing
volume shows up as an authentication failure in the Cal.com log rather than as anything about
passwords.

Do not paste that file, any of the three secrets, or any command output containing them into
this chat window. `CALENDSO_ENCRYPTION_KEY` deserves one extra sentence: every Google or Outlook
connection you later add is encrypted with it, so it cannot be rotated and it cannot be lost.

Now the mail relay. Cal.com sends the confirmation to the person who booked, so an install with
no relay tells nobody anything. Open the file and replace the five `CHANGE_ME` values:

```bash
nano /srv/calcom/.env
grep -c CHANGE_ME /srv/calcom/.env || true
```

You should see: `0` from the second command. Set `EMAIL_FROM` to the address your relay is
allowed to send from, `EMAIL_FROM_NAME` to whatever you want invitees to read, and the three
`EMAIL_SERVER_` values from the relay's own dashboard. Correct `EMAIL_SERVER_PORT` if it is not
587.

If you do not: any number above `0` means a `CHANGE_ME` is still sitting there. That `grep`
counts lines and prints no value, which is why it is safe to run with this chat window open. Do
not paste your relay password here.

## 4. compose.yml

Paste the whole block at once, including the last line.

```bash
cat > /srv/calcom/compose.yml <<'EOF'
# Cal.com · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository. Read at tag v6.2.0 of
# https://github.com/calcom/cal.diy : docs/self-hosting/docker.mdx, .env.example,
# Dockerfile and scripts/start.sh.
#
# Two services: the Cal.com web app and the PostgreSQL holding every event type,
# booking and calendar credential. Upstream's compose file builds from source
# beside an API container and a Prisma Studio; this one pulls the published
# image. Its entrypoint rewrites the URL baked into the build to
# NEXT_PUBLIC_WEBAPP_URL and migrates on every fresh container, so a first boot
# takes minutes and the health check below adds a start period.
#
# Digests read on 2026-08-05. The v6.2.0 tag is amd64 only; arm64 ships as the
# separate v6.2.0-arm tag, not one multi-architecture manifest.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: calcom-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: calcom
      POSTGRES_USER: calcom
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/calcom/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U calcom -d calcom"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  calcom:
    image: calcom/cal.com:v6.2.0@sha256:ace3bb1219fb7306585ab9f4d94d41af7ee064c343db0498173436bbe857bd49
    container_name: calcom
    restart: unless-stopped
    env_file: /srv/calcom/.env
    environment:
      # start.sh waits on this host:port pair before migrating.
      DATABASE_HOST: postgres:5432
      DATABASE_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
      # Prisma migrates over the direct URL. No pooler, so the same address.
      DATABASE_DIRECT_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://localhost:3000 || exit 1"]
      interval: 30s
      timeout: 30s
      retries: 5
      start_period: 900s
    ports:
      # Loopback only: the host's Caddy is all that reaches 8094.
      - "127.0.0.1:8094:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
```

Then, only if step 1 printed `arm64`, switch the image line. On amd64 this changes nothing:

```bash
if [ "$(dpkg --print-architecture)" = "arm64" ]; then
  sed 's|:v6.2.0@sha256:[a-f0-9]*|:v6.2.0-arm@sha256:4b0fa72eec13bd3ddb608a6d13f05bf0ebc136e73832abfe1a8ec145db9e4651|' /srv/calcom/compose.yml > /srv/calcom/compose.arm && mv /srv/calcom/compose.arm /srv/calcom/compose.yml
fi
grep -n 'image:' /srv/calcom/compose.yml
cd /srv/calcom && docker compose config >/dev/null && echo "compose OK"
```

You should see: two `image:` lines, the Cal.com one ending in `-arm` only if you are on arm64,
then `compose OK` and nothing else.

If you do not: `env file /srv/calcom/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/calcom/compose.yml` and paste again in one go. There is no password in this file,
which is deliberate: compose reads `${POSTGRES_PASSWORD}` out of /srv/calcom/.env when it builds
the two connection strings, so the file stays safe to show somebody.

## 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-calcom
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Cal.com · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/calcom/cal.diy/blob/v6.2.0/docs/self-hosting/docker.mdx
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# NEXT_PUBLIC_WEBAPP_URL in .env: the container rewrites its own built-in URL to
# match on every fresh start, so the two have to agree exactly.

<DOMAIN> {
	# Every invitee lands on a page from here, so a downgrade has a real
	# audience. No X-Frame-Options on purpose: booking pages are meant to be
	# embedded in other people's sites.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8094 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8094
}
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-calcom /etc/caddy/Caddyfile`, reload, and
paste again. The hostname in this block and `NEXT_PUBLIC_WEBAPP_URL` in .env have to be the same
string, because the container rewrites its compiled-in URL to whatever .env says and Caddy is
what answers on that name.

## 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 `8094` or `5432`.

If you do not: delete anything for `8094` or `5432` with `sudo ufw delete allow 8094`. 8094 is
bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the database has
no host port a firewall rule could apply to. `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

This is the long one. On a fresh container the entrypoint rewrites every compiled-in copy of the
built URL, waits for PostgreSQL, applies the database migrations and seeds the app store before
Next.js listens on anything. Start it and leave it alone.

```bash
cd /srv/calcom
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' "https://<DOMAIN>/auth/setup?step=1"); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS "https://<DOMAIN>/auth/setup?step=1" | grep -o '<title>[^<]*</title>'
curl -sS -o /dev/null -w '%{http_code}\n' "https://<DOMAIN>/signup"
```

You should see, in order: several minutes of `502`, then the loop reaching `200`, then the line
`<title>Setup | Cal.com</title>`, then `200`. That last `200` means registration is open, which
is correct for now and closed at the end of this step.

If you do not: run `docker compose logs --tail 60 calcom`. `Replacing all statically built
instances` means it is rewriting the URL and has not finished. Migration names scrolling past
mean the database is being built. Either way it is working and needs more time. A loop that
expires with `502` and a log that is not moving is different: check
`docker compose logs --tail 20 postgres` first, because a database that never reports healthy is
step 2 done wrong. `CLIENT_FETCH_ERROR` in the Cal.com log means `NEXT_PUBLIC_WEBAPP_URL` from
step 3 is not the hostname Caddy is serving.

Now open https://<DOMAIN>/auth/setup?step=1 in a browser. The first screen is a wizard headed
`Administrator user`, with `Let's create the first administrator user.` under it. Create that
account. The password rule is upstream's and it is strict: at least 15 characters, one number,
and both cases. Step 2 of the wizard asks you to choose a licence, and the free AGPLv3 option is
the one this install runs under. Step 3 offers to enable apps; you can skip it.

With the account made, close registration so nobody else can create one:

```bash
printf 'NEXT_PUBLIC_DISABLE_SIGNUP=true\n' >> /srv/calcom/.env
cd /srv/calcom
docker compose up -d --force-recreate calcom
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' "https://<DOMAIN>/auth/login"); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' "https://<DOMAIN>/signup"
```

You should see: another slow loop, because a recreated container rewrites the built URL again,
and then a 3xx status whose redirect URL contains `/auth/error`.

If you do not: a `200` from that last command means the variable did not take. Sign in and turn
on the `disable-signup` flag at https://<DOMAIN>/settings/admin/flags instead, then run the last
command again. One thing that is not a fault: the `Create an account` link stays visible on the
login page, because that link is drawn by JavaScript compiled into the image, while the page
behind it now refuses. A green `docker compose ps` is not success; that redirect to `/auth/error`
is.

## 8. First backup and restore

Two artifacts. The database holds every event type, booking and encrypted calendar credential.
The config archive holds what rebuilds the service around it.

```bash
cd /srv/calcom
docker compose exec -T postgres pg_dump -U calcom -d calcom | gzip > /srv/calcom/backups/calcom-db-$(date +%F).sql.gz
sudo tar -C /srv/calcom -czf /srv/calcom/backups/calcom-config-$(date +%F).tar.gz compose.yml .env
ls -lh /srv/calcom/backups/
```

You should see: two files, the dump a few hundred kilobytes on a fresh install and the config
archive a couple of kilobytes. Nothing goes offline: `pg_dump` snapshots a running database
consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

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/calcom
scp vps:/srv/calcom/backups/* ~/backups/calcom/
```

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

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 one empty account:

```bash
cd /srv/calcom
docker compose down
sudo rm -rf /srv/calcom/postgres
sudo install -d -m 700 /srv/calcom/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/calcom/backups/calcom-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U calcom -d calcom
docker compose up -d
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then the containers coming back. Wait
for the slow first boot again, then sign in with the account you made in step 7. If it lets you
in, the restore worked.

If you do not: `role "calcom" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the config archive
is for before you skip it: `CALENDSO_ENCRYPTION_KEY` lives in .env, every calendar credential in
that dump is encrypted with it, and a database restored beside a newly generated key is a table
of unreadable tokens.

## 9. Updating later

New versions are listed at https://github.com/calcom/cal.diy/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/calcom/compose.yml to the new tag and its
digest, keeping the `-arm` suffix if you are on arm64.

```bash
cd /srv/calcom
docker compose pull
docker compose up -d
docker compose logs --tail 40 calcom
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done. One thing to know about this project's
release cadence: the repository was renamed to cal.diy after 6.2.0 and no release has been cut
under the new name, so there may be nothing to update to for a while.

## 10. What will probably go wrong

The first boot looks like a failed install for a long time. The entrypoint greps and rewrites
every file in the compiled Next.js output before doing anything else, then waits for PostgreSQL,
then runs the migrations, then seeds the app store, and only then does anything listen on 3000.
I watched Caddy return `502` for nine minutes on a 4 GB box and went looking for what I had
broken. Nothing was. Run `docker compose logs -f calcom` and read it instead of restarting:
while `Replacing all statically built instances` or a Prisma migration name is on screen, it is
working. Restarting begins that sequence again.

## 11. Out of scope

- Do not configure Google Calendar or Outlook sync. Both need an OAuth client you register in
  your own Google Cloud or Azure tenant, a separate sitting.
- Do not enable organizations. `ORGANIZATIONS_ENABLED` wants a wildcard subdomain and a second
  hostname, and this install serves one domain.
- Do not add the API v2 container or Prisma Studio from upstream's compose file.
- Do not set `CALCOM_LICENSE_KEY`. That is the commercial licence for the enterprise features,
  and this install runs the free one the wizard offers in step 7.

344 lines · 14,913 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 Cal.com 6.2.0, with the PostgreSQL it stores bookings in, under ~/selfhost/calcom,
answering at http://localhost:8094.

## 1. Preflight

Say this to the user before step 2; it decides whether they want this install at all. Cal.com's
product is a link other people open to book time with you, and this one is
http://localhost:8094, which means "this computer" wherever it is read. Nobody else can open it,
their own phone included. They get a scheduling app they drive themselves and a page nobody
else can load.

Detect the OS and measure:

```bash
uname -s
uname -m
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. `uname -m`
decides one line in step 5: an Apple Silicon Mac prints `arm64`. This install needs 4096 MB of
RAM available and 15 GB free on the home disk, and Docker Desktop's virtual machine wants at
least 4 GB of that in its own settings. If either floor is missed, print both numbers and stop.

## 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/calcom/backups
ls -la ~/selfhost/calcom
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: bookings and
calendar credentials are rows in PostgreSQL, kept in a volume Docker manages, so no ownership
fix is needed.

## 4. Secrets

Three secrets: the PostgreSQL password, the NextAuth session secret, and the encryption key over
saved calendar credentials. Generate all three here, print none, keep them out of your summary
and logs. Upstream documents `openssl rand -base64 32` and `openssl rand -base64 24` for
the two Cal.com ones.

```bash
cd ~/selfhost/calcom
umask 077
cat > .env <<EOF
NEXT_PUBLIC_WEBAPP_URL=http://localhost:8094
NEXT_PUBLIC_WEBSITE_URL=http://localhost:8094
CALCOM_TELEMETRY_DISABLED=1
POSTGRES_PASSWORD=$(openssl rand -hex 32)
NEXTAUTH_SECRET=$(openssl rand -base64 32)
CALENDSO_ENCRYPTION_KEY=$(openssl rand -base64 24)
EMAIL_FROM=CHANGE_ME
EMAIL_FROM_NAME=CHANGE_ME
EMAIL_SERVER_HOST=CHANGE_ME
EMAIL_SERVER_PORT=587
EMAIL_SERVER_USER=CHANGE_ME
EMAIL_SERVER_PASSWORD=CHANGE_ME
EOF
chmod 600 .env
umask 022
ls -l .env
```

Assert: mode `-rw-------`. On Windows those bits are advisory; the real boundary is the user's
own account. `CALENDSO_ENCRYPTION_KEY` cannot be regenerated: calendar connections are
encrypted with it. The five `CHANGE_ME` lines are the outbound relay, and an invitee learns
their booking exists by mail or not at all.

STOP: tell the user to open ~/selfhost/calcom/.env in an editor, replace every `CHANGE_ME` with
the matching value from their mail relay, correct `EMAIL_SERVER_PORT` if it is not 587, save,
and confirm. Do not continue until they do, and never ask them to paste those values to you.

```bash
grep -c CHANGE_ME .env || true
```

Assert: that prints `0`. It counts lines, never values.

## 5. compose.yml

```bash
cat > ~/selfhost/calcom/compose.yml <<'EOF'
# Cal.com · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://github.com/calcom/cal.diy at tag v6.2.0
# (docs/self-hosting/docker.mdx, .env.example, Dockerfile, scripts/start.sh),
# not copied from a repository.
#
# Every path is relative to ~/selfhost/calcom/, so one file works on macOS,
# Linux and Windows. The database is a named volume rather than a bind mount
# because the PostgreSQL image chowns its data directory to its own uid, which
# Docker Desktop's Windows file sharing cannot grant on a home directory. The
# entrypoint rewrites the built-in URL to NEXT_PUBLIC_WEBAPP_URL and migrates on
# every fresh container, so a first boot takes minutes and the health check
# below adds a start period.
#
# Digests read on 2026-08-05. The v6.2.0 tag is amd64 only; arm64 ships as the
# separate v6.2.0-arm tag.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: calcom-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: calcom
      POSTGRES_USER: calcom
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - calcom-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U calcom -d calcom"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  calcom:
    image: calcom/cal.com:v6.2.0@sha256:ace3bb1219fb7306585ab9f4d94d41af7ee064c343db0498173436bbe857bd49
    container_name: calcom
    restart: unless-stopped
    env_file: ./.env
    environment:
      # start.sh waits on this host:port pair before migrating.
      DATABASE_HOST: postgres:5432
      DATABASE_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
      # Prisma migrates over the direct URL. No pooler, so the same address.
      DATABASE_DIRECT_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://localhost:3000 || exit 1"]
      interval: 30s
      timeout: 30s
      retries: 5
      start_period: 900s
    ports:
      # Loopback only: no other device on the wifi can reach 8094.
      - "127.0.0.1:8094:3000"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  calcom-pgdata:
EOF
```

That pins the amd64 build. Upstream ships no multi-architecture manifest, so if step 1 printed
`arm64` or `aarch64`, switch the image line:

```bash
cd ~/selfhost/calcom
case "$(uname -m)" in
  arm64|aarch64) sed 's|:v6.2.0@sha256:[a-f0-9]*|:v6.2.0-arm@sha256:4b0fa72eec13bd3ddb608a6d13f05bf0ebc136e73832abfe1a8ec145db9e4651|' compose.yml > compose.arm && mv compose.arm compose.yml ;;
esac
grep -n 'image:' compose.yml
docker compose config >/dev/null && echo "compose OK"
```

Assert: `grep` prints two image lines, the Cal.com one carrying `-arm` on arm64, and the last
prints `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway.
- No firewall rule. Nothing is published beyond loopback, so nothing needs closing.

8094 is bound to 127.0.0.1: not the phone, not a laptop on the wifi, not anyone they want to
meet. Confirm:

```bash
grep -n '127.0.0.1' compose.yml
```

Assert: one line, `- "127.0.0.1:8094:3000"`. PostgreSQL publishes none.

## 7. Start and verify

On a fresh container the entrypoint rewrites every compiled-in copy of the built URL, waits for
PostgreSQL, migrates and seeds before Next.js listens. Budget fifteen minutes.

```bash
cd ~/selfhost/calcom
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' "http://localhost:8094/auth/setup?step=1"); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS "http://localhost:8094/auth/setup?step=1" | grep -o '<title>[^<]*</title>'
```

Assert both, and print what you received. The loop ends printing `200`, and the title line reads
exactly `<title>Setup | Cal.com</title>`. If either misses, stop, run
`docker compose logs --tail 60 calcom`, and name the cause: a database that never reports
healthy points at step 4, where an empty `POSTGRES_PASSWORD` stops PostgreSQL starting; a log
still printing migration names wants more time; `port is already allocated` means something else
holds 8094. A running container is not success.

The first screen at http://localhost:8094/auth/setup?step=1 is a wizard headed
`Administrator user`, with `Let's create the first administrator user.` under it.

STOP: tell the user to open that address, create the first account, and wait. Do not continue
until they confirm. Upstream's password rule is strict: 15 characters at least, one number, both
cases. Step 2 asks for a licence, and the free AGPLv3 option is this one.

Once they confirm, close registration and prove it is closed:

```bash
cd ~/selfhost/calcom
printf 'NEXT_PUBLIC_DISABLE_SIGNUP=true\n' >> .env
docker compose up -d --force-recreate calcom
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' "http://localhost:8094/auth/login"); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' "http://localhost:8094/signup"
```

Assert: the last line is a 3xx status whose redirect URL contains `/auth/error`, and the loop is
slow again because recreating rewrites the built URL. The `Create an account` link stays on the
login page, drawn by JavaScript compiled into the image, but the page behind it refuses.

## 8. First backup and restore

Two artifacts: the database holds every booking and encrypted calendar credential, the config
archive what rebuilds the service around it.

```bash
cd ~/selfhost/calcom
docker compose exec -T postgres pg_dump -U calcom -d calcom | gzip > backups/calcom-db-$(date +%F).sql.gz
tar -czf backups/calcom-config-$(date +%F).tar.gz compose.yml .env
ls -lh backups/
```

Assert: both exist and are non-empty. Print both sizes. `pg_dump` snapshots a running database,
so nothing stops.

Both sit on the same disk as the data, which is not a backup, 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 listed there. If they have neither, say plainly that there is no backup.

To restore, in this order. In ~/selfhost/calcom, untar the config archive first, so compose.yml
and .env are back before any container starts: PostgreSQL takes `POSTGRES_PASSWORD` from .env
the moment it initialises an empty volume. Then `docker compose down -v`, the one place `-v`
belongs, `docker compose up -d postgres`, wait 30 seconds, pipe `gunzip -c` on the
`.sql.gz` into `docker compose exec -T postgres psql -U calcom -d calcom`, then
`docker compose up -d`. The archive matters as much as the dump: those credentials are encrypted
with a key that lives only in .env.

## 9. Updating later

New versions are listed at https://github.com/calcom/cal.diy/releases. Take both backups first,
then edit the image line in compose.yml to the new tag and digest, keeping `-arm` on an arm64
machine:

```bash
cd ~/selfhost/calcom
docker compose pull
docker compose up -d
docker compose logs --tail 40 calcom
```

Watch that log until it settles, then re-run step 7's health check before calling it done.

## 10. What will probably go wrong

The first boot looks like a failed install for a long time. I watched `curl` return nothing at
all for eleven minutes on a MacBook and started reading the compose file for a mistake. There
was none: the entrypoint was still rewriting the compiled output, then migrating, then seeding.
Read `docker compose logs -f calcom` rather than restarting, because a restart begins that whole
sequence again.

## 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 `NEXT_PUBLIC_WEBAPP_URL` to this machine's LAN address and do not rebind 8094 to
  0.0.0.0 so a phone can reach it. That puts a calendar on every network the user joins.
- Do not configure Google Calendar or Outlook sync, which needs an OAuth client registered in
  the user's own Google Cloud or Azure tenant, and do not set `CALCOM_LICENSE_KEY`.
compose.local.ymlthe services, pinned · local layout61 lines

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

# Cal.com · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://github.com/calcom/cal.diy at tag v6.2.0
# (docs/self-hosting/docker.mdx, .env.example, Dockerfile, scripts/start.sh),
# not copied from a repository.
#
# Every path is relative to ~/selfhost/calcom/, so one file works on macOS,
# Linux and Windows. The database is a named volume rather than a bind mount
# because the PostgreSQL image chowns its data directory to its own uid, which
# Docker Desktop's Windows file sharing cannot grant on a home directory. The
# entrypoint rewrites the built-in URL to NEXT_PUBLIC_WEBAPP_URL and migrates on
# every fresh container, so a first boot takes minutes and the health check
# below adds a start period.
#
# Digests read on 2026-08-05. The v6.2.0 tag is amd64 only; arm64 ships as the
# separate v6.2.0-arm tag.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: calcom-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: calcom
      POSTGRES_USER: calcom
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - calcom-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U calcom -d calcom"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  calcom:
    image: calcom/cal.com:v6.2.0@sha256:ace3bb1219fb7306585ab9f4d94d41af7ee064c343db0498173436bbe857bd49
    container_name: calcom
    restart: unless-stopped
    env_file: ./.env
    environment:
      # start.sh waits on this host:port pair before migrating.
      DATABASE_HOST: postgres:5432
      DATABASE_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
      # Prisma migrates over the direct URL. No pooler, so the same address.
      DATABASE_DIRECT_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://localhost:3000 || exit 1"]
      interval: 30s
      timeout: 30s
      retries: 5
      start_period: 900s
    ports:
      # Loopback only: no other device on the wifi can reach 8094.
      - "127.0.0.1:8094:3000"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  calcom-pgdata:

agent-readable mirror: /self-host/calendly.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, pinned57 lines

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

# Cal.com · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository. Read at tag v6.2.0 of
# https://github.com/calcom/cal.diy : docs/self-hosting/docker.mdx, .env.example,
# Dockerfile and scripts/start.sh.
#
# Two services: the Cal.com web app and the PostgreSQL holding every event type,
# booking and calendar credential. Upstream's compose file builds from source
# beside an API container and a Prisma Studio; this one pulls the published
# image. Its entrypoint rewrites the URL baked into the build to
# NEXT_PUBLIC_WEBAPP_URL and migrates on every fresh container, so a first boot
# takes minutes and the health check below adds a start period.
#
# Digests read on 2026-08-05. The v6.2.0 tag is amd64 only; arm64 ships as the
# separate v6.2.0-arm tag, not one multi-architecture manifest.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: calcom-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: calcom
      POSTGRES_USER: calcom
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/calcom/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U calcom -d calcom"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  calcom:
    image: calcom/cal.com:v6.2.0@sha256:ace3bb1219fb7306585ab9f4d94d41af7ee064c343db0498173436bbe857bd49
    container_name: calcom
    restart: unless-stopped
    env_file: /srv/calcom/.env
    environment:
      # start.sh waits on this host:port pair before migrating.
      DATABASE_HOST: postgres:5432
      DATABASE_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
      # Prisma migrates over the direct URL. No pooler, so the same address.
      DATABASE_DIRECT_URL: postgresql://calcom:${POSTGRES_PASSWORD}@postgres:5432/calcom
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://localhost:3000 || exit 1"]
      interval: 30s
      timeout: 30s
      retries: 5
      start_period: 900s
    ports:
      # Loopback only: the host's Caddy is all that reaches 8094.
      - "127.0.0.1:8094:3000"
    depends_on:
      postgres:
        condition: service_healthy
Caddyfilethe hostname and TLS26 lines

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

# Cal.com · the Caddy site block for this service. Authored by caniselfhostit
# from https://github.com/calcom/cal.diy/blob/v6.2.0/docs/self-hosting/docker.mdx
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# NEXT_PUBLIC_WEBAPP_URL in .env: the container rewrites its own built-in URL to
# match on every fresh start, so the two have to agree exactly.

<DOMAIN> {
	# Every invitee lands on a page from here, so a downgrade has a real
	# audience. No X-Frame-Options on purpose: booking pages are meant to be
	# embedded in other people's sites.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# 8094 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8094
}
install.shthe same install, no agent221 lines

authored from upstream docs, never pasted · 9,465 bytes

#!/usr/bin/env bash
# Cal.com · 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=cal.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation, read at tag v6.2.0
# of https://github.com/calcom/cal.diy :
#   docs/self-hosting/docker.mdx
#   .env.example
#   Dockerfile
#   scripts/start.sh
#
# Three secrets are generated here, on this machine: the PostgreSQL password,
# the NextAuth session secret, and the encryption key over saved calendar
# credentials. All three go into /srv/calcom/.env with mode 600 and none is ever
# printed. CALENDSO_ENCRYPTION_KEY cannot be rotated: every calendar connection
# added later is encrypted with it.
#
# This script stops twice for you. Once to put your mail relay credentials into
# .env, because Cal.com mails the confirmation to whoever booked. Once to create
# the first administrator account in a browser, which only a human can do.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/calcom}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
CALCOM_TAG="v6.2.0"
CALCOM_ARM_PIN="v6.2.0-arm@sha256:4b0fa72eec13bd3ddb608a6d13f05bf0ebc136e73832abfe1a8ec145db9e4651"

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. cal.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 4096 ] || die "only ${avail_mb} MB of RAM available; a Next.js server plus PostgreSQL wants 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 15 ] || die "only ${avail_gb} GB free on /srv; the image alone is 1.5 GB compressed"

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

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

# Upstream publishes no multi-architecture manifest: the arm64 build of the same
# release is a separate tag with an -arm suffix.
if [ "$(dpkg --print-architecture)" = "arm64" ]; then
	sed "s|:${CALCOM_TAG}@sha256:[a-f0-9]*|:${CALCOM_ARM_PIN}|" "$APP_DIR/compose.yml" > "$APP_DIR/compose.arm"
	mv "$APP_DIR/compose.arm" "$APP_DIR/compose.yml"
fi

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Base64 for the two Cal.com values because that is what upstream documents, hex
# for the database password because it also travels inside a connection string.
# Read them later with
#   grep -E 'POSTGRES_PASSWORD|NEXTAUTH_SECRET|CALENDSO_ENCRYPTION_KEY' /srv/calcom/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		NEXT_PUBLIC_WEBAPP_URL=https://${DOMAIN_HOST}
		NEXT_PUBLIC_WEBSITE_URL=https://${DOMAIN_HOST}
		CALCOM_TELEMETRY_DISABLED=1
		POSTGRES_PASSWORD=$(openssl rand -hex 32)
		NEXTAUTH_SECRET=$(openssl rand -base64 32)
		CALENDSO_ENCRYPTION_KEY=$(openssl rand -base64 24)
		EMAIL_FROM=CHANGE_ME
		EMAIL_FROM_NAME=CHANGE_ME
		EMAIL_SERVER_HOST=CHANGE_ME
		EMAIL_SERVER_PORT=587
		EMAIL_SERVER_USER=CHANGE_ME
		EMAIL_SERVER_PASSWORD=CHANGE_ME
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

# --- 4. The mail relay, which only you can supply ----------------------------

unset_lines="$(grep -c CHANGE_ME "$APP_DIR/.env" || true)"
if [ "$unset_lines" != "0" ]; then
	cat >&2 <<-STOPMAIL

		STOP. Cal.com mails the confirmation to whoever booked, so an install with
		no outbound relay tells nobody anything.

		Open $APP_DIR/.env, replace every CHANGE_ME with the matching value from
		your mail relay, correct EMAIL_SERVER_PORT if it is not 587, then run
		this script again.

	STOPMAIL
	exit 1
fi

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

# --- 5. 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-calcom"
	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

# --- 6. Ports: two open, and neither 8094 nor 5432 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; 8094 and 5432 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 7. Start it -------------------------------------------------------------
#
# A fresh container rewrites every compiled-in copy of the built URL, waits for
# PostgreSQL, applies the Prisma migrations and seeds the app store before
# Next.js listens on anything. Minutes, not seconds.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/auth/setup?step=1 (this takes minutes)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/auth/setup?step=1" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/auth/setup answered ${code:-nothing}. Check: docker compose logs --tail 60 calcom"

curl -sS "https://${DOMAIN_HOST}/auth/setup?step=1" | grep -q '<title>Setup | Cal.com</title>' \
	|| die "the setup page answered 200 without the expected title. Check: docker compose logs --tail 60 calcom"

# Registration is open on a fresh install. Step 9 closes it and proves it.
open_signup="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/signup" || true)"
[ "$open_signup" = "200" ] || die "/signup answered ${open_signup}, not 200. The app is not fully up."

# --- 8. The first administrator account, which only a human can create -------

cat <<-STOPSETUP

	STOP. Open https://${DOMAIN_HOST}/auth/setup?step=1 in a browser now.

	The first screen is a wizard headed "Administrator user" with the line
	"Let's create the first administrator user." under it. Create that account.
	The password rule is upstream's: at least 15 characters, one number, and
	both cases. Step 2 asks you to pick a licence; the free AGPLv3 option is the
	one this install runs under.

STOPSETUP
printf 'Press Enter once the account exists. '
read -r _

# --- 9. Close registration, and prove it is closed ---------------------------

if ! grep -q '^NEXT_PUBLIC_DISABLE_SIGNUP=' "$APP_DIR/.env"; then
	printf 'NEXT_PUBLIC_DISABLE_SIGNUP=true\n' >> "$APP_DIR/.env"
fi
docker compose up -d --force-recreate calcom

echo "==> waiting for the recreated container (it rewrites the built URL again)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/auth/login" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/auth/login answered ${code:-nothing} after the restart"

signup="$(curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' "https://${DOMAIN_HOST}/signup" || true)"
case "$signup" in
	3*auth/error*) : ;;
	*) die "/signup returned '${signup}' rather than a redirect to /auth/error. Registration is still open." ;;
esac

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

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U calcom -d calcom | gzip > "$APP_DIR/backups/calcom-db-${STAMP}.sql.gz"
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/calcom-config-${STAMP}.tar.gz" compose.yml Caddyfile .env
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/calcom-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Cal.com is answering at https://${DOMAIN_HOST}

	  1. Registration is closed. The "Create an account" link is still drawn on
	     the login page, because that link is compiled into the image, but the
	     page behind it redirects to /auth/error. Only your account exists.
	  2. Your three secrets are in $APP_DIR/.env, mode 600. None was printed
	     here. CALENDSO_ENCRYPTION_KEY is the one you cannot replace: every
	     calendar connection you add later is encrypted with it.
	  3. 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:
	       scp vps:$APP_DIR/backups/* ~/backups/calcom/
	     Restore needs both. A dump restored beside a newly generated
	     encryption key is a table of unreadable tokens.
	  4. Google and Outlook calendar sync are not configured. They need an
	     OAuth client you register in your own Google Cloud or Azure tenant.

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

  • You are installing 6.2.0, which is Cal.com under AGPLv3 with a commercially licensed ee directory inside it. Since that release the project renamed its repository to cal.diy, relicensed everything MIT and deleted the enterprise code, but it has cut no release and pushed no image under the new name, so the newest thing you can honestly pin is still the old one.
  • You own a PostgreSQL and one key. Every booking, event type and calendar credential is a row in that database, and the credentials are encrypted with CALENDSO_ENCRYPTION_KEY from your .env. Back the two up together, because a dump restored beside a freshly generated key is a table of unreadable tokens.
  • Mail is the product, not a nicety. An invitee only learns their booking exists because your server emailed them, so this install stops and asks for relay credentials before it starts anything.
  • Calendar sync is a separate afternoon. Cal.com keeps its own calendar and books fine without Google or Outlook, but until you register an OAuth client in your own Google Cloud or Azure tenant it does not know about the meetings already in your work calendar, and catching the double bookings is your job.
  • No teams, no round-robin routing, no insights dashboards, no SSO. Those live in the ee directory behind a commercial licence key, and they are most of what the paid Teams tier sells.

Where this came from

“Use at your own risk. Cal.diy is the open source community edition of Cal.com and it is intended for users who want to self-host their own Cal.diy instance. It is strictly recommended for personal, non-production use.”

  • The published image's entrypoint, scripts/start.sh, replaces the URL compiled into the build with whatever NEXT_PUBLIC_WEBAPP_URL is set to at container start, then applies the Prisma migrations and seeds the app store before the server listens. source
  • The image is built with NEXT_PUBLIC_WEBAPP_URL baked to http://localhost:3000, records that value as BUILT_NEXT_PUBLIC_WEBAPP_URL for the entrypoint to replace, and exposes port 3000. source
  • Upstream publishes no multi-architecture manifest for the release: ARM users are told to pull the same version with a -arm suffix on the tag. source
  • NEXTAUTH_SECRET is documented as openssl rand -base64 32, and CALENDSO_ENCRYPTION_KEY as openssl rand -base64 24, which is the 32-character key the AES-256 encryption of saved credentials requires. source
  • The 6.2.0 release this install pins is licensed AGPLv3 except for the packages/features/ee and apps/api/v2/src/ee directories, which carry a separate commercial licence. source

Questions people actually ask

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

  • Can I self-host Calendly?

    Not Calendly 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 Cal.com. 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 install is one weekend: 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 300 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 Calendly?

    Cal.com. 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 only one here that is the same product rather than a smaller cousin: event types with buffers and minimum notice, a public booking page, calendar sync, webhooks and an API. The costs are honest ones. It is the heaviest install in this category, wanting 4 GB and a PostgreSQL, its first boot genuinely looks broken for ten minutes, and the teams and routing features that justify Calendly's Teams tier sit behind a commercial licence key you will not have. Take it if you want your own booking domain and one person's calendar; look elsewhere if you came for round robin. Cal.com is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Calendly?

    4096 MB of RAM and 15 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Cal.com itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Calendly Standard, $12/mo — $144 a year, 1 seat assumed.

  • How hard is it really?

    ONE WEEKEND — 3–24 hours. The rule that produced that verdict: a database plus one outside integration. A database and an outside service fail in completely different ways, and you have to learn both failure modes before you trust the thing with real data. Budget the second day for whichever one surprises you. The tier is derived from seven countable facts about the Cal.com install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Cal.com 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 Cal.com 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: Nobody but you can open http://localhost:8094, so on this path you get a scheduling app you drive yourself rather than a link you can hand to the person you are trying to meet. 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-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.