Workload · Running your own booking page

Cal.com self hosted, on a server that answers only to you

If you want Cal.com self-hosted, what you install today is Cal.diy: in April 2026 the open-source repository was renamed, its enterprise features taken out and its licence changed from AGPL-3.0 to MIT. You get a personal booking page with calendar sync, compiled and run with Docker beside its own PostgreSQL database. Its makers mean it for personal use, not production; weigh that, then follow the steps below.

The short version
  1. You compile the image yourself; there is none to download
  2. Two keys in .env must outlive every rebuild
  3. Create your admin login while the site is still private
  4. Google and Outlook sync need OAuth apps in your name
  5. Nightly dump and settings archive, kept two weeks

First, what you are actually installing

Cal.diy is the community edition of Cal.com. Visit calcom/cal.com on GitHub and you land on calcom/cal.diy; the Cal.com documentation pages about self-hosting forward you to cal.diy too. One commit on 15 April 2026 did it all: new name, Enterprise Edition and its licence checks stripped out, and MIT in place of AGPL-3.0.

For one person taking bookings, the essentials are there: event types, availability and buffers, paid bookings via Stripe or PayPal, Apple, CalDAV, Exchange, Google and Outlook calendar sync, Zoom, Google Meet, Microsoft Teams or Jitsi links, webhooks and embeds. Gone, among others, per the project's comparison table: Routing Forms, Workflows, Insights, Organizations, teams with round-robin, instant meetings, impersonation, delegation, SAML single sign-on and the Admin Panel. The old calcom/docker repository is archived too; the Dockerfile and compose file now live in calcom/cal.diy.

The project describes Cal.diy as community maintained and used at your own risk, and it recommends it for personal use only, not production. If a sales team will live on this booking page, decide with that in mind.

Is a whole server worth it for a booking page?

Running it is light, the project says; compiling is the heavy part, repeated at every upgrade, and the Dockerfile lets it use up to 6,144 MB of Node.js memory. Our suggestion, as the project gives no figure: 16 GB of RAM or more.

Rent one of our dedicated servers and you hold root on hardware no other customer touches, so nothing competes with a build. Rack servers and blades include a console screen and power button you open yourself: your way in when a firewall mistake shuts SSH. Both the everyday one-processor models and the two-processor ones fit. Choose x86-64, since these steps are written for it; the ARM machines are out (Ampere Altra, and the X-Gene cartridge, which only installs Ubuntu 16.04 or 18.04). A ready server is handed over within 30 minutes of approval, and you can cancel when any month ends.

Before you start

  • A hostname you control, such as cal.example.com, that you can point at the server.
  • Ubuntu 24.04 plus Docker Engine (version 28 at least) and its Compose plugin, both installed the way Docker's own install guide describes.
  • A mailbox that can send over SMTP, for booking confirmations.
  • Your personal SSH login. Each block runs as root, so start with sudo -i.

Step 1: lock the door and write the settings

Swap cal.example.com for your hostname, then paste. The firewall comes up allowing only SSH, the code lands in /opt/cal, and a private settings file is made from the project's example. Already have /opt/cal/.env? Then nothing happens, deliberately: a second run would mint a new encryption key and make your saved calendar passwords unreadable.

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH && ufw --force enable
v=$(docker version -f '{{.Server.Version}}' 2>/dev/null); v=${v%%.*}
if [ "${v:-0}" -ge 28 ] && [ ! -e /opt/cal/.env ]; then
  git clone https://github.com/calcom/cal.diy.git /opt/cal && cd /opt/cal &&
  install -m 600 .env.example .env.new &&
  a=$(openssl rand -base64 32) && b=$(openssl rand -base64 24) &&
  c=$(openssl rand -hex 32) && p=$(openssl rand -hex 24) &&
  [ -n "$a" ] && [ -n "$b" ] && [ -n "$c" ] && [ -n "$p" ] &&
  U=https://cal.example.com && D="postgresql://cal:$p@database:5432/calendso" &&
  sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$a|" \
    -e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$b|" \
    -e "s|^CRON_API_KEY=.*|CRON_API_KEY=$c|" \
    -e "s|^NEXT_PUBLIC_WEBAPP_URL=.*|NEXT_PUBLIC_WEBAPP_URL=$U|" \
    -e "s|^NEXTAUTH_URL=.*|NEXTAUTH_URL=$U|" \
    -e "s|^NEXT_PUBLIC_DISABLE_SIGNUP=.*|NEXT_PUBLIC_DISABLE_SIGNUP=true|" \
    -e "s|^DATABASE_URL=.*|DATABASE_URL=$D|" \
    -e "s|^DATABASE_DIRECT_URL=.*|DATABASE_DIRECT_URL=$D|" .env.new &&
  printf '%s\n' POSTGRES_USER=cal "POSTGRES_PASSWORD=$p" POSTGRES_DB=calendso >> .env.new &&
  grep -qx "CALENDSO_ENCRYPTION_KEY=$b" .env.new && grep -qx "DATABASE_URL=$D" .env.new &&
  mv -n .env.new .env && [ ! -e .env.new ] && echo "ok: /opt/cal/.env written"
else echo "stopped: needs Docker Engine 28+ and no /opt/cal/.env yet"; fi

What it fills in: the cookie secret and the encryption key, made with the two openssl commands from the README (the key must be exactly 32 bytes long, as AES-256 needs); your public address; a database password. Two extras are ours. The example file ships a well-known value for the key that guards the scheduled-job routes, so it gets a random one, and sign-ups are switched off with NEXT_PUBLIC_DISABLE_SIGNUP, a setting the sign-up route reads while the app runs. Briefly, the new secrets show in ps output as sed runs, and both keys go into the image build: keep that image on your server, never in a public registry.

Step 2: build it and start it

The README suggests downloading a ready image, yet the calcom/cal.diy page on Docker Hub listed no versions when we looked (23 October 2026), so you compile one. The README's compile steps want the database running during the build and turn BuildKit off (DOCKER_BUILDKIT=0) so a network bridge is usable while compiling. One line is ours: network: calnet under build, which puts the compile step on the database's network. Your address is written into the result (the start script patches it if you change it later), and both secrets must match at compile time and run time, which one shared .env guarantees.

cd /opt/cal && [ -e .env ] && [ ! -e compose.cal.yaml ] && cat > compose.cal.yaml <<'EOF'
name: cal
services:
  database:
    image: postgres:18
    restart: always
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes: [db:/var/lib/postgresql]
    networks: [calnet]
  calcom:
    image: cal-diy:local
    build:
      context: .
      network: calnet
      args:
        NEXT_PUBLIC_WEBAPP_URL: ${NEXT_PUBLIC_WEBAPP_URL}
        NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
        CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY}
        DATABASE_URL: ${DATABASE_URL}
    restart: always
    env_file: .env
    environment:
      DATABASE_HOST: database:5432
    ports: ["127.0.0.1:3000:3000"]
    depends_on: [database]
    networks: [calnet]
networks:
  calnet: {name: calnet}
volumes:
  db: {}
EOF
v=$(docker version -f '{{.Server.Version}}' 2>/dev/null); C="docker compose -f /opt/cal/compose.cal.yaml"
[ "${v%%.*}" -ge 28 ] && [ -e /opt/cal/compose.cal.yaml ] && $C up -d database && sleep 15 &&
$C exec -T database pg_isready -q && DOCKER_BUILDKIT=0 $C build calcom &&
$C up -d calcom && $C ps

We cut the repository's compose file down to the database and the web app, pinned PostgreSQL to 18 with its password from .env, added that build network, and kept the app on 127.0.0.1:3000. Left out: Redis (6379), Prisma Studio (5555), which the original says to remove in production, and the API v2 service (port 80). No API v2 here, then: for it, bring back the original's calcom-api and redis entries, published on 127.0.0.1 only.

Why bind to 127.0.0.1? A port Docker publishes without an address listens on 0.0.0.0 and [::], and Docker lets those connections skip ufw over IPv4 (IPv6 too, if you switched on Docker's IPv6 support). From Engine 28, a loopback binding stays inside the machine: that is what the version check is for.

Step 3: claim the admin account privately

The setup wizard opens with an administrator form, and its code gives that role to whoever submits first while no user exists, so use a private tunnel before the site is public.

  1. On your laptop, open the tunnel with your usual login and leave it running: ssh -L 9000:127.0.0.1:3000 your-user@server-address.
  2. Visit http://localhost:9000/auth/setup and fill in the form. Your password needs 15 characters or more, at least one number, and capitals as well as small letters.
  3. Your account is saved once the form goes through; a failed redirect to your real hostname afterwards is expected, as nothing serves it yet. Stuck on the calendar step? The README says to go straight to /event-types.
  4. Create the DNS record for your hostname, then install Caddy, a web server that gets its own certificate: apt install -y caddy on Ubuntu 24.04, then printf 'cal.example.com\nreverse_proxy 127.0.0.1:3000\n' > /etc/caddy/Caddyfile && systemctl restart caddy.
  5. Only now let visitors in: ufw allow 80,443/tcp. Caddy runs directly on the server, so this single rule opens it over both IPv4 and IPv6.

Caddy is our pick; the project leaves the proxy to you.

Connecting Google or Outlook, and sending mail

Calendar sync needs an app you register with Google or Microsoft, per the Cal.diy Apps pages. After each change, edit /opt/cal/.env in nano, save, and restart the app with the up -d calcom command from Step 2.

  1. Google Calendar: in Google Cloud, turn on the Calendar API and set up the consent screen with two scopes, calendar.readonly and calendar.events; list your own Google accounts as test users. Make a Web application client ID whose allowed redirects are https://cal.example.com/api/auth/callback/google and https://cal.example.com/api/integrations/googlecalendar/callback. Download its JSON, paste it as the GOOGLE_API_CREDENTIALS value, and keep Google sign-in off with GOOGLE_LOGIN_ENABLED=false.
  2. Outlook or Microsoft 365: add an app registration in Azure that is multitenant (any organizational directory) and give it the Web redirect https://cal.example.com/api/integrations/office365calendar/callback. Its Application (client) ID goes in MS_GRAPH_CLIENT_ID; create a client secret for MS_GRAPH_CLIENT_SECRET.
  3. Booking emails: the EMAIL_ lines in .env take the sender address and display name plus your SMTP host, port, username and password. The mail code (packages/lib/serverConfig.ts) encrypts from the first byte when the port is 465; with no host at all it tries a sendmail program inside the container instead, which is rarely what you want.

Google's page ends with an app-store re-seed, which scripts/start.sh already runs at every start, so as we read it the restart covers it.

Keeping your bookings safe

Everything the app remembers sits in its database, so a nightly dump plus .env and the compose file is a complete backup. Never leave .env out. Its encryption key protects, among other things, two-factor secrets with their backup codes, the deployment's signing token, the SendGrid key and other app keys, ICS-feed addresses, saved Apple, CalDAV and Exchange passwords, and the accept and decline links in booking-request emails. Restore with a different key and you reconnect those calendars and apps, redo two-factor, and any request links still waiting stop working.

install -d -m 700 /root/cal-backups
cat > /root/cal-backup.sh <<'EOF'
set -o pipefail; umask 077
d=/root/cal-backups; f=$d/cal-db-$(date +%F).sql.gz
docker compose -f /opt/cal/compose.cal.yaml exec -T database \
  sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' | gzip > "$f.part" &&
gzip -t "$f.part" && mv "$f.part" "$f" &&
tar -czf "$d/cal-conf-$(date +%F).tgz" -C /opt/cal .env compose.cal.yaml &&
find "$d" -name 'cal-db-*.sql.gz' -mtime -1 | grep -q . &&
find "$d" -name 'cal-*' -mtime +14 -delete
EOF
cat > /root/cal-restore.sh <<'EOF'
set -o pipefail; umask 077
B="${1}"; P=/root/cal-backups/pre-restore-$(date +%F-%H%M%S).sql.gz
C="docker compose -f /opt/cal/compose.cal.yaml"
gzip -t "$B" && [ ! -e "$P" ] && $C stop calcom &&
$C exec -T database sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' | gzip > "$P" &&
gzip -t "$P" && echo "current database saved as $P" &&
$C exec -T database sh -c 'dropdb -U "$POSTGRES_USER" "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"' &&
gunzip -c "$B" | $C exec -T database sh -c 'psql -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" "$POSTGRES_DB"' &&
$C up -d calcom && echo "restored from $B"
EOF
chmod 700 /root/cal-backup.sh /root/cal-restore.sh
printf '%s\n' '20 3 * * * root bash /root/cal-backup.sh > /root/cal-backups/last.log 2>&1' > /etc/cron.d/cal-backup
bash /root/cal-backup.sh && ls -l /root/cal-backups

Restoring is one command: sudo bash /root/cal-restore.sh with the dump's path after it. It tests the file, stops the app, saves and verifies a copy of the current database (printing its path), then loads your dump and starts the app. Unhappy? Run sudo bash /root/cal-restore.sh on that printed copy to go back.

Keep copies elsewhere too: pull /root/cal-backups to another machine on your own schedule with rsync -a, never --delete. You manage the server; we keep the hardware and the network running. Moving to a new server? Run Step 1's two firewall lines, clone into /opt/cal, extract your newest cal-conf archive into that folder while the database has never run (so PostgreSQL gets the old password), run Step 2's last lines and after them the restore script; Step 3's Caddy and ufw steps come last.

Upgrading: take a backup first, then git -C /opt/cal pull, look through .env.example for new settings, and rerun the last lines of Step 2. Each container start runs prisma migrate deploy, so pending migrations land before the app comes up.

The licence, and what this guide leaves to you

The LICENSE file is MIT, copyright Cal.com, Inc. This is our general reading and not legal advice: MIT lets you run, change and share the code, and only asks that any copy carries the original copyright line and the MIT text; it never makes you publish your changes. Before the April 2026 switch the code was licensed AGPL-3.0, while the enterprise folder carried a commercial licence, and an old checkout keeps those terms. The names Cal.diy and Cal are trademarks registered to Cal.com, Inc., says the docs footer.

Left out: the installation page says a few features rely on cron jobs, whose routes sit in the folder apps/web/app/api/cron (by subfolder name: reminders for bookings, calendar subscriptions, webhook triggers). Without them, those won't fire on their own. Liked this? Twenty CRM and Vikunja are set up in a similar way.

Questions people ask

Why is there no ready-made Docker image?
The documentation names calcom/cal.diy on Docker Hub, but on 23 October 2026 that listing had no versions at all. Compiling from the repository is the README's other documented route, and the one this guide takes.
Can I rename my booking address later?
Yes. The address goes into the image when it is compiled, but the start script swaps in the run-time NEXT_PUBLIC_WEBAPP_URL whenever the two differ, at the cost of a slower start. Change NEXTAUTH_URL and the Caddyfile to match, plus the redirect addresses in your Google and Microsoft apps.
Will it work for my whole team?
Not as a team tool. Cal.diy leaves out teams, round-robin and collective events, Workflows and Routing Forms, and the project only recommends it for personal use. Several people can still hold their own accounts if you let them register.
How do I keep my calendar connections after a crash?
Back up .env with every dump. Its key unlocks, among other things, saved calendar passwords, ICS-feed addresses, app keys and two-factor secrets; with another key those must be set up again, and pending booking-request links stop working.

Give your booking page a home of its own

Your own machine with full root access, nothing metered on the port and no contract, handed over within 30 minutes of approval when it is ready on the shelf.

Get a server for your scheduler

Read next