You want your own booking page. Someone picks a slot, it lands on your calendar, nobody trades six emails to find a time. Calendly does that, but it rents you the feature per seat and keeps your scheduling data on its servers. Cal.com is the open-source version you run on your own box, and once it is up the experience is the same: a clean public link, real calendar sync, your data on your disk.
This guide self-hosts Cal.com on Ubuntu with Docker and takes it all the way to a real setup: Postgres, the two secrets that matter, the setup wizard, a working booking, and an Nginx front end with HTTPS. It uses the official prebuilt image so you get a running instance in minutes instead of compiling a large Next.js app. If you specifically want a zero-enterprise, fully MIT build, there is a section for that too, along with the traps it carries right now.
Ran this end to end on Ubuntu 24.04 in September 2026, with a spot check on 26.04. Docker Engine 29.8, Cal.com v6.2.0.
Two ways to self-host Cal.com, and which to pick
There are two supported paths, and the difference is licensing versus effort.
- The prebuilt image (
calcom/cal.com). Published on Docker Hub, versioned, and ready to pull. The core app is AGPLv3, which is free to self-host; only the enterprise features under theeedirectory need a paid license, and you do not need them for personal or team booking pages. This is the path this guide uses. - Building cal.diy from source. cal.diy is Cal.com repackaged under a clean MIT license with no enterprise code at all. There is no published image, so you compile it yourself. Pick this only if AGPL is a real problem for you, and read the source-build section first, because the current tree has two build breaks you will hit.
The instance makes the same call on first run. The setup wizard asks you to pick the free AGPLv3 license or enter a paid enterprise key, and for a self-hosted booking page the free option is all you need.

One thing to ignore regardless of path: any guide pointing at the calcom/docker repository. That repo was archived when the Docker resources moved, and the community effort continues as cal.diy. Most of the setup here is the same shape as any Compose stack you already run, close to self-hosting Supabase with Docker Compose.
Prerequisites
- A server you can reach over SSH. Tested end to end on Ubuntu 24.04 LTS, with a spot check on 26.04 LTS. The steps are identical either way, since everything runs in Docker.
- Memory: 2 GB is enough for the prebuilt-image path, since you are only running the app and Postgres. Building cal.diy from source is a different story and wants 8 GB, covered in that section.
- Disk: 10 GB or more. The image plus the database volume is modest, but leave headroom.
- A domain with an A record pointing at the server, and port 80 reachable, for the HTTPS step. Any DNS provider works.
- Root or a sudo user.
1. Install Docker and the Compose plugin
Use Docker’s own apt repository, not the distro package, so you get the current Engine and the Compose v2 plugin. Add the key and the source, then install the engine.
sudo apt update
sudo apt install -y ca-certificates curl git openssl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/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/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm both the engine and the Compose plugin answer before moving on.
docker --version
docker compose version
Your versions should sit in this range or newer.
Docker version 29.8.1, build 4a63305
Docker Compose version v5.5.1
If you run Docker as a non-root user, add yourself to the docker group with sudo usermod -aG docker $USER and log back in. New to Docker on this distro? The longer walkthrough lives in our guide on how to install Docker and run containers on Ubuntu.
2. Create the project and the Compose file
Make a directory for the stack. Two files live here: a Compose file that pulls the app and Postgres, and an .env that holds your secrets.
mkdir ~/calcom
cd ~/calcom
Write docker-compose.yml. Pin the image to a released tag rather than latest, so an upgrade is a deliberate choice and not a surprise on the next pull.
services:
database:
image: postgres:16
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db-data:/var/lib/postgresql/data
calcom:
image: calcom/cal.com:v6.2.0 # https://github.com/calcom/cal.com/releases
restart: always
depends_on:
- database
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}
DATABASE_DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: http://localhost:3000
CALENDSO_ENCRYPTION_KEY: ${CALENDSO_ENCRYPTION_KEY}
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
CALCOM_TELEMETRY_DISABLED: "1"
volumes:
db-data:
The app reads its database credentials, its session secret, and its public URL from the environment. Postgres reads the matching user and password to create the database on first run. The next step fills in the values.
3. Generate the secrets and the database variables
Cal.com signs sessions with NEXTAUTH_SECRET and encrypts stored calendar credentials with CALENDSO_ENCRYPTION_KEY. Generate real values, never ship the defaults. The encryption key is the one to guard: change it after you have connected a Google or Office calendar and every stored token becomes unreadable, which silently breaks the connection.
openssl rand -base64 32
openssl rand -base64 24
Create .env next to the Compose file. Use the first value for NEXTAUTH_SECRET, the second for CALENDSO_ENCRYPTION_KEY, and set a strong database password.
POSTGRES_USER=calcom
POSTGRES_PASSWORD=use-a-strong-password-here
POSTGRES_DB=calendso
NEXTAUTH_SECRET=paste-the-32-byte-value-here
CALENDSO_ENCRYPTION_KEY=paste-the-24-byte-value-here
Back up CALENDSO_ENCRYPTION_KEY somewhere outside the server before you connect a single calendar. Losing it is not recoverable, you re-add every integration by hand.
4. Start the stack and watch the migrations
Pull the images and bring the stack up. The app image runs its database migrations on first start, so the container is up a few seconds before the app actually answers. It is ready when the migrations finish and the server reports it is listening.
docker compose pull
docker compose up -d
Follow the app log until the migrations apply and the Next.js server starts.
docker compose logs -f calcom
The log scrolls through every migration, then prints that the server is ready.
+ npx prisma migrate deploy --schema /calcom/packages/prisma/schema.prisma
Prisma schema loaded from packages/prisma/schema.prisma
588 migrations found in prisma/migrations
Applying migration `20210605225044_init`
Applying migration `20210605225507_added_bookings`
...
All migrations have been successfully applied.
✓ Ready in 534ms
Confirm both containers are up and the app reports healthy.
docker compose ps
Both containers show as running, with the app one flagged healthy once its start-up probe passes.

The web app answers on port 3000. Open http://SERVER_IP:3000 in a browser, or tunnel it over SSH with ssh -L 3000:localhost:3000 user@server and open http://localhost:3000. Access it on the same URL you set for NEXT_PUBLIC_WEBAPP_URL, which matters for the reason covered in the HTTPS step.
5. Create your admin account
The first load drops you into a setup wizard at /auth/setup. This creates the single admin user that owns the instance. Set a real email and a strong password. The wizard enforces a policy, so the password needs a minimum length plus at least one number and one uppercase letter, and a short all lowercase value bounces with a validation error.

After the admin step the wizard walks you through your username, connecting a calendar, and setting availability. You can skip the calendar connection for now and add it later from settings, then you land on the dashboard.

The sidebar is your whole workspace: event types, bookings, availability, teams, apps, and workflows. The footer confirms the exact build you are running.
6. Create an event type and test a booking
An event type is the thing people book: a 30-minute intro, a 15-minute standup, whatever you offer. Cal.com ships a few by default. Open one to see its public link, then load that link in a private window to book against yourself and prove the round trip works.

The public page shows your available slots and takes the booking. This is the exact view your invitees see.

Availability controls when those slots are offered. Set your working hours once and every event type inherits them unless you override per event.

Booking confirmation emails need an SMTP server. Until you set EMAIL_FROM and the EMAIL_SERVER_* variables, the app works but sends nothing, so wire in your mail provider before you rely on reminders.
7. Put it behind Nginx with HTTPS
Port 3000 over plain HTTP is fine for a first look and wrong for anything real. Calendar OAuth and shareable booking links need a proper hostname and a valid certificate. Terminate TLS with Nginx in front of the app.
Set your domain and email once so the commands below read cleanly.
export DOMAIN="cal.example.com"
export EMAIL="[email protected]"
Point the app at your domain. Change NEXT_PUBLIC_WEBAPP_URL and NEXTAUTH_URL in the Compose file to https://cal.example.com, then recreate the app container so it reads the new values. The prebuilt image applies the public URL at container start, so a plain up is enough here, no rebuild.
sed -i "s#http://localhost:3000#https://${DOMAIN}#g" docker-compose.yml
docker compose up -d
Install Nginx and Certbot, point a server block at the app, and let Certbot fetch the certificate over the HTTP-01 challenge. This is the provider-agnostic path, it only needs port 80 open and the A record resolving.
sudo apt install -y nginx certbot python3-certbot-nginx
Create the site with a proxy pass to port 3000. The WebSocket upgrade headers matter, the app uses them for live availability.
sudo tee /etc/nginx/sites-available/calcom >/dev/null <<NGINX
server {
listen 80;
server_name ${DOMAIN};
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
NGINX
sudo ln -s /etc/nginx/sites-available/calcom /etc/nginx/sites-enabled/calcom
sudo nginx -t && sudo systemctl reload nginx
Issue the certificate and let Certbot rewrite the server block for TLS and the HTTP redirect.
sudo certbot --nginx -d "${DOMAIN}" --non-interactive --agree-tos --redirect -m "${EMAIL}"
Load https://cal.example.com and you should land on your instance with a valid padlock. If your server sits on a private LAN with no public port 80, switch the certificate to a DNS-01 challenge instead: install the plugin for your DNS provider (python3-certbot-dns-cloudflare, -dns-route53, -dns-digitalocean, and others exist) and run certbot certonly --dns-<provider> with an API token, then reference the certificate paths in the Nginx block by hand. Prefer a tunnel over opening ports? A tool like Pangolin, a self-hosted reverse proxy, fronts the app without a public IP at all.
The MIT route: building cal.diy from source
If you want zero enterprise code and a clean MIT license, cal.diy is the build. There is no published image, so you compile it. Clone the repo and check out a release tag rather than main, because main is a moving development target and can be mid-break on any given day.
git clone https://github.com/calcom/cal.diy.git
cd cal.diy
git checkout v6.2.0
cp .env.example .env
Set the same secrets in .env, then wire up the database connection. There is a trap worth knowing about first. The bundled docker-compose.yml pins the Postgres credentials as literals directly on the database service (unicorn_user, magical_password, calendso) and only feeds .env to the app container, not to the database. So Postgres is always created with those stock values, and the app’s DATABASE_URL has to match them exactly or the connection is rejected with password authentication failed. Keep the stock credentials as shown below, and if you want a password of your own, change it on the database service in the compose file too, not only in .env. The one value the example file genuinely omits is DATABASE_HOST.
POSTGRES_USER=unicorn_user
POSTGRES_PASSWORD=magical_password
POSTGRES_DB=calendso
DATABASE_HOST=database:5432
DATABASE_URL=postgresql://unicorn_user:magical_password@database:5432/calendso
Now build the image. This compiles the whole app and is memory-hungry, so a small box needs swap or the Next build gets out-of-memory killed. Give it 8 GB of headroom between RAM and swap.
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
docker compose build calcom
Two build breaks are live in the tree as of this writing, both covered in the troubleshooting section below: a Turbo flag change and a TypeScript error in the web build. Apply those fixes, let the build finish, then bring the stack up the same way as the prebuilt path.
docker compose up -d database redis calcom
Fixing the errors you will actually hit
failed to resolve reference “calcom/cal.diy”: not found
You tried to docker compose up the cal.diy source tree without building first. cal.diy publishes no image, so the pull fails. Run docker compose build calcom, then bring the stack up. The prebuilt path in this guide does not hit this, because calcom/cal.com is a real published image.
turbo prune: the argument ‘–scope’ cannot be used multiple times
The cal.diy source build dies early, inside the builder stage, with this:
x the argument '--scope' cannot be used multiple times
1 | prune --scope=@calcom/web --scope=@calcom/trpc --docker
ERROR: process "/bin/sh -c npx turbo prune --scope=@calcom/web --scope=@calcom/trpc --docker" did not complete successfully: exit code: 1
Current Turbo dropped the repeatable --scope flag and takes the workspace names as plain arguments, but the Dockerfile still uses the old form. Rewrite that one line to positional syntax, then build again.
sed -i 's/npx turbo prune --scope=@calcom\/web --scope=@calcom\/trpc --docker/npx turbo prune @calcom\/web @calcom\/trpc --docker/' Dockerfile
Type error: ‘CacheProvider’ cannot be used as a JSX component
The cal.diy web build gets most of the way, then fails the type check:
Failed to type check.
Type error: 'CacheProvider' cannot be used as a JSX component.
ERROR: process "/bin/sh -c yarn --cwd apps/web workspace @calcom/web run build" did not complete successfully: exit code: 1
This is a React type-version clash in the build, not a runtime fault. The app runs fine once compiled. Skip the type check for the build by adding typescript: { ignoreBuildErrors: true } to apps/web/next.config.js, then build again. This is exactly the kind of moving-target breakage that makes the prebuilt image the better default for most people.
Booking links and assets point at localhost
You put Nginx in front on your domain but a copied booking link or a redirect still says localhost:3000. NEXT_PUBLIC_WEBAPP_URL is still on the old value. On the prebuilt image, set it to your domain in the Compose file and run docker compose up -d to recreate the container. On a cal.diy source build the URL is compiled in, so you set it and rebuild the image, editing .env alone will not move it.
Port 3000 already in use
Another service holds the port. Free it, or remap the app by changing the published port in docker-compose.yml from 3000:3000 to something like 3001:3000 and pointing Nginx at the new host port. The container still listens on 3000 inside.
Taking this from a lab box to production
A running instance is not a production instance. Three things move it there. Back up the PostgreSQL volume on a schedule, because that database holds every booking, user, and calendar link; a nightly docker compose exec database pg_dump to off-box storage is the minimum. Back up CALENDSO_ENCRYPTION_KEY separately and treat it like a private key, since losing it orphans every connected calendar. And pin your updates: bump the image tag deliberately, read the release notes, and snapshot the database first. If eyeballing container health from the terminal gets old, point Portainer at the same Docker host and watch the stack from a web UI. Do those three and Cal.com is a genuinely low-maintenance service that quietly replaces a per-seat SaaS bill.