Use case · Scheduling

Cal.com self hosted: building Cal.diy on a dedicated server

Self-hosting Cal.com now means running Cal.diy, the MIT-licensed community edition the old calcom/cal.com repository was renamed to in April 2026: one Next.js app plus PostgreSQL, built from source because the Docker Hub image it points to has no tags. Its own documentation calls it strictly recommended for personal, non-production use. This page builds it on a whole machine, keeps the app on 127.0.0.1:3000 behind Caddy, opens 80 and 443 only after the first administrator exists, and covers calendars, SMTP, backups and upgrades.

See the machines

What "self-hosted Cal.com" is in 2026

GitHub redirects calcom/cal.com to calcom/cal.diy; the commit behind it (refactor: Cal.diy, #28903, 15 April 2026) rebranded the code, removed the Enterprise Edition features and licence checks, and moved the licence from AGPL-3.0 to MIT. The old cal.com/docs self-hosting URLs redirect to cal.diy, and the calcom/docker repository is archived: its notice says the Docker files moved into the main repository.

The docs compare the two products feature by feature. Kept in Cal.diy: event types, recurring and seated events, paid events through Stripe or PayPal, availability schedules, Google, Outlook, Apple, CalDAV and Exchange calendars, Zoom, Google Meet, Microsoft Teams and Jitsi, webhooks, embeds. Missing, among others: Teams with round-robin or collective events, managed event types, instant meetings, Organizations, Workflows, Routing Forms, Insights, SAML SSO, SCIM, impersonation, delegation, API v1 and the Admin Panel. The project lists API v2 as kept, but this install has no API v2: add the repo's calcom-api and redis services, bound to 127.0.0.1, if you need it.

The docs index warns: use at your own risk, community maintained, strictly recommended for personal, non-production use, and self-hosting needs real knowledge of server administration, databases and securing sensitive data.

Load on the machine

The installation page says Cal.diy needs little hardware once running and that building is the intensive part; it gives no figures. The Dockerfile sets a 6,144 MB Node.js heap for the build (MAX_OLD_SPACE_SIZE). Our sizing, not the project's: 16 GB of RAM, so that heap, PostgreSQL and the OS fit at once during every upgrade build.

PieceListens onReached by
Cal.diy web app (Next.js)127.0.0.1:3000Caddy on the host; you, through an SSH tunnel
PostgreSQL 18 containerthe compose network onlythe app container; pg_dump via compose exec
Caddy (Ubuntu package)TCP 80 and 443the internet, once the admin exists

Choosing the server

The single- and dual-socket rack lines in our dedicated server range take 16 GB and more, and every racked single-, dual- and quad-socket server has a full out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC): the way back in when a bad ufw rule cuts SSH. Ports are unmetered on every line marked so.

Use an x86-64 line: the commands target x86-64 Ubuntu 24.04, so leave out both ARM lines, Ampere Altra as well as the X-Gene cartridge (which boots only Ubuntu 16.04 or 18.04). Expect about 30 minutes between payment approval and handover for a racked machine, and 4 to 24 hours for a build to order. Ready servers lists the racked shelf.

Secrets and the .env file

In a root shell (sudo -i) on Ubuntu 24.04 with Docker Engine 28+ and the Compose plugin, put your hostname in place of cal.example.com, then paste. The block enables ufw with SSH only and writes /opt/cal/.env at mode 600 from the repository's .env.example. It refuses if .env exists: a rerun would generate a fresh CALENDSO_ENCRYPTION_KEY and orphan every secret encrypted with the old one.

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

The two openssl commands are the README's: openssl rand -base64 32 for NEXTAUTH_SECRET (cookie encryption) and openssl rand -base64 24 for CALENDSO_ENCRYPTION_KEY, which the README says must be 32 bytes for AES-256. Ours on top: a random CRON_API_KEY, since .env.example ships a fixed one and the cron routes trust whoever presents it (apps/web/app/api/cron/bookingReminder/route.ts), and NEXT_PUBLIC_DISABLE_SIGNUP=true, which the sign-up API route reads at run time (apps/web/app/api/auth/signup/route.ts). The generated values sit on sed's command line for a moment, where ps on the box can see them, and the build hands both keys to the image build as arguments: keep the image on this machine rather than pushing it to a public registry.

Building the image and the first start

The README's quick path is docker compose pull, but on 23 October 2026 calcom/cal.diy on Docker Hub had no tags, so build it. The README's from-source path needs a reachable database during the build and sets DOCKER_BUILDKIT=0 so the build can use a network bridge. The network: calnet line under build is ours: it puts the build on the database's network. NEXT_PUBLIC_WEBAPP_URL is baked in at build time; when the run-time value differs, scripts/start.sh rewrites the built files at start, with a short delay. NEXTAUTH_SECRET and CALENDSO_ENCRYPTION_KEY must match their build values, per the README.

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

The compose file is ours, simplified from the repository's docker-compose.yml: only database and calcom kept (the repo also runs Redis on 6379, the API v2 container on port 80 and Prisma Studio on 5555, on every address, and says to remove Studio in production); PostgreSQL pinned to 18 instead of the bare postgres tag, with credentials from .env rather than the samples; four build arguments; network: calnet under build; port 3000 bound to 127.0.0.1. Leaving out calcom-api means no API v2.

Why the binding: a mapping without an address publishes on 0.0.0.0 and [::], and Docker's rules let that traffic past ufw on IPv4. That holds for IPv6 when the daemon runs its own IPv6 networking. A 127.0.0.1 binding is local-only from Docker Engine 28, hence the version gate in both blocks.

The administrator, then HTTPS

The setup wizard's first step creates the administrator (apps/web/modules/auth/setup-view.tsx), and its route makes whoever submits first a user with role ADMIN while no user exists (apps/web/app/api/auth/setup/route.ts). Claim it privately.

  1. From your workstation: ssh -N -L 8080:127.0.0.1:3000 you@your-server, as your own user.
  2. Browse to http://localhost:8080/auth/setup and create the administrator. The route wants 15 characters or more, a digit, upper and lower case.
  3. The account is stored when the form is accepted. A redirect to your public hostname may fail until the proxy exists; the README also says the calendar step can be skipped by going to /event-types.
  4. Point DNS at the server, apt install -y caddy (Ubuntu 24.04 universe), then printf '%s\n' 'cal.example.com {' ' reverse_proxy 127.0.0.1:3000' '}' > /etc/caddy/Caddyfile and systemctl reload caddy.
  5. Last, ufw allow 80,443/tcp. Caddy is a host process, so until that rule exists ufw drops its traffic on IPv4 and IPv6.

The docs give no proxy configuration; Caddy is our choice.

Google and Outlook calendars, and mail

  • Google (docs, Apps > Google): enable the Google Calendar API, configure the OAuth consent screen with the calendar.events and calendar.readonly scopes, add your accounts as test users, create an OAuth client of type Web application with redirect URIs /api/integrations/googlecalendar/callback and /api/auth/callback/google on your hostname, paste the downloaded JSON into GOOGLE_API_CREDENTIALS and set GOOGLE_LOGIN_ENABLED=false.
  • Microsoft (docs, Apps > Microsoft): register an Azure app for accounts in any organizational directory, Web redirect URI /api/integrations/office365calendar/callback on your hostname, then MS_GRAPH_CLIENT_ID and a new client secret in MS_GRAPH_CLIENT_SECRET.
  • SMTP: EMAIL_FROM, EMAIL_FROM_NAME, EMAIL_SERVER_HOST, EMAIL_SERVER_PORT, EMAIL_SERVER_USER, EMAIL_SERVER_PASSWORD. packages/lib/serverConfig.ts uses implicit TLS on port 465 and falls back to a sendmail binary inside the container when no host is set.

Edit with nano /opt/cal/.env and apply with docker compose -f /opt/cal/compose.cal.yaml up -d calcom. The Google page ends with re-seeding the app store; scripts/start.sh runs that seed on every start, so in our reading the restart covers it.

Backups, restore and upgrades

The database volume is the only persistent state; back it up with .env and the compose file. CALENDSO_ENCRYPTION_KEY encrypts, among other things, stored CalDAV, Apple and Exchange credentials (packages/app-store/caldavcalendar/api/add.ts and its Apple and Exchange siblings), ICS-feed calendar URLs, app keys such as SendGrid's, the deployment signature token, two-factor secrets and backup codes (apps/web/app/api/auth/two-factor/totp/setup/route.ts), and the accept/decline tokens in booking-request emails. A dump restored without that key leaves all of it undecryptable: those calendars and apps must be re-added, two-factor reset, and outstanding request links 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

To restore: sudo bash /root/cal-restore.sh /root/cal-backups/cal-db-DATE.sql.gz. It checks the file, stops the app, dumps the current database to a pre-restore file and verifies it before dropping anything, then loads the backup and starts the app. To roll back, rerun it with sudo on the pre-restore path it printed. The .env archive holds every secret: copy /root/cal-backups off the box yourself with plain rsync -a, never --delete, and prune on the far side.

New machine, as root: the two ufw lines from the .env block first, then clone to /opt/cal, unpack the cal-conf archive there before the database container first starts (PostgreSQL then initialises with your saved password), run the build block's last lines, then the restore script, and only then the Caddy and ufw allow 80,443/tcp steps. Upgrade: back up, cd /opt/cal && git pull, diff .env.example against .env for new variables, rerun the build and up -d calcom lines. scripts/start.sh runs prisma migrate deploy before the app starts.

Licence and what is left out

LICENSE in calcom/cal.diy is MIT, copyright Cal.com, Inc.; the README says the whole codebase is MIT with no Enterprise Edition split. Our general reading, not legal advice: MIT is permissive, so running, changing or passing on copies only obliges you to keep the copyright notice and licence text; there is no duty to publish source. Code from before April 2026 was AGPL-3.0 with a commercial licence for the enterprise folder, so an older checkout keeps those terms. The docs footer names Cal.diy and Cal as registered trademarks of Cal.com, Inc.

Skipped here: the installation page says some features need cron jobs, under apps/web/app/api/cron (booking reminders, calendar subscriptions, webhook triggers and more, by folder name); without them those features do not run on schedule. Comparable builds: Twenty CRM and Kimai.

Questions

Is the Docker image prebuilt, or built per URL?

The docs name calcom/cal.diy on Docker Hub, which had no tags on 23 October 2026, so you build it. NEXT_PUBLIC_WEBAPP_URL is baked into static files at build time, and scripts/start.sh rewrites it at container start when the run-time value differs, so a hostname change does not strictly need a rebuild.

Does Cal.diy do team scheduling?

No. Its feature table marks Teams, round-robin and collective team events, managed event types, Organizations, Workflows, Routing Forms and Insights as absent. The project recommends it for personal, non-production use.

What breaks if CALENDSO_ENCRYPTION_KEY is lost?

Everything encrypted with it (see the backup section). Bookings and event types stay readable, but calendar and app connections must be added again, two-factor reset, and pending request links stop working. Keep .env with every dump.

Is sign-up open to the public?

Not with this setup. NEXT_PUBLIC_DISABLE_SIGNUP=true is checked by the sign-up API route at run time, and the first-user setup route refuses once any user exists. The administrator is created over the SSH tunnel before Caddy and the web ports go live.

Put the scheduler on its own machine

Rack servers with an out-of-band console, root and an unmetered port, billed monthly with no contract.

See the machines

Related on Server Room

Other use cases