Self-Hosting cal.diy on an arm64 Box

cal.diy is Cal.com’s community fork with the enterprise code removed. Its README says 100% MIT and no licence key required. It now runs at cal.danieljohnmorris.com, on the box that already carries analytics, error tracking and DMARC reporting.

Here we will explore the four things that took the time:

  • the image the README advertises, which has no tags in it
  • why the build moved to GitHub’s arm64 runners after eleven minutes of watching an SSH pipe
  • a GHCR package that is private no matter what you do, until you rename it
  • ALLOWED_HOSTNAMES, which is not a list of hostnames you allow

The box is a Hetzner CAX, 4 vCPU and 8 GB, arm64, carrying around 40 containers behind Dokploy. Before this went on it, free -m reported 4,252 MB available and /proc/pressure/memory reported zero stall at every window, so the question was not whether there was room but how much of it cal.diy wanted.

The image in the README has zero tags

cal.diy’s README carries a Docker Hub pull-count badge and its docker-compose.yml pulls calcom.docker.scarf.sh/calcom/cal.diy. The repository behind that:

curl -s https://hub.docker.com/v2/repositories/calcom/cal.diy/tags | jq .count
# 0

GHCR returns NAME_UNKNOWN for the same name. calcom/cal.com does publish 406 tags, arm64 included as vX.Y.Z-arm, but that is Cal.com with the enterprise code in it, which is the thing the fork exists to remove.

So the image has to be built. cal.diy’s Dockerfile begins FROM --platform=$BUILDPLATFORM node:20 AS builder and defaults MAX_OLD_SPACE_SIZE to 6144, which rules out building on the box: a 6 GB Node heap does not fit in 4.2 GB.

Building it locally was the easy half

On an M3 Max, native arm64, docker build finished in 8m45s. The result is 8 GB, and almost all of it is one layer:

5.19GB  COPY /calcom ./ # buildkit
1.36MB  RUN apt-get install netcat-openbsd wget
8.19kB  WORKDIR /calcom

Two builds failed before that one. The first died on no space left on device inside the Docker VM, with docker system df reporting 20.63 GB of reclaimable build cache. The second died on rpc error: code = Unavailable desc = error reading from server: EOF during yarn install, which reads like a buildkit fault and was the whole VM going down: I had resized its disk, and Docker Desktop restarted it again after reporting the restart complete.

Then the shipping. There is no registry in this setup, so the established move on this box is to stream the image in, the same way the Libredesk fork gets there:

docker save cal.diy:v6.2.0 | gzip -1 \
  | ssh -p 2222 dan@host 'gunzip | sudo docker load'

Eleven minutes in, du -sh /mnt/volume-1/docker/tmp on the far end read 673 MB of the 8 GB tar. That is about 1 MB/s of tar, and roughly 0.34 MB/s of compressed bytes leaving the house. Two hours, for an image I would want to rebuild on every cal.diy release.

The box downloads three orders of magnitude faster than I upload

The number that decided it:

# on the box
curl -s -o /dev/null -w '%{speed_download}\n' \
  --max-time 15 https://hel1-speed.hetzner.com/100MB.bin
# 442 MB/s

Domestic upstream against a datacentre downlink is not a fight worth having, so the build moved to where the bytes already are. cal.diy’s own release workflow builds arm64 on ubuntu-24.04-arm hosted runners, which is proof the build fits on one, so a small repository checks out calcom/cal.diy at a tag, builds its Dockerfile unmodified, and pushes to GHCR:

- uses: actions/checkout@v4
  with:
    repository: calcom/cal.diy
    ref: ${{ inputs.ref }}
- uses: docker/build-push-action@v6
  with:
    platforms: linux/arm64
    push: true
    tags: ghcr.io/danieljohnmorris/cal.diy:${{ inputs.ref }}-arm64
    build-args: |
      NEXT_PUBLIC_WEBAPP_URL=${{ inputs.webapp_url }}
      MAX_OLD_SPACE_SIZE=6144

Nineteen minutes on the runner. The pull on the box took 5m16s, and that time is decompression on 4 vCPU rather than network.

NEXT_PUBLIC_WEBAPP_URL has to be a build argument because Next.js inlines NEXT_PUBLIC_* into the client bundle during next build. cal.diy handles this better than most: the builder stage bakes a literal placeholder, and scripts/start.sh runs replace-placeholder.sh against the built output when the runtime value differs from the baked one. Passing the real URL at build time skips that pass. Worth checking what else gets baked, since NEXTAUTH_SECRET and CALENDSO_ENCRYPTION_KEY are build arguments too:

docker image inspect ghcr.io/danieljohnmorris/cal.diy:v6.2.0-arm64 \
  --format '{{range .Config.Env}}{{println .}}{{end}}'

Three variables come back: NEXT_PUBLIC_WEBAPP_URL, BUILT_NEXT_PUBLIC_WEBAPP_URL and NODE_ENV. The secrets are set on the builder stage only and the runner stage copies /calcom rather than the environment, so a public image is safe to publish.

A GHCR package is private until it shares a repository’s name

The pull failed anonymously, which was expected, and then would not stop failing. Making a package public is a settings change, and the API for it wants read:packages or write:packages:

You need at least read:packages scope to get a package. (HTTP 403)

The workflow’s own GITHUB_TOKEN cannot do it either; packages: write covers pushing, not visibility. The rule that gets you out of this is that a package whose name matches a repository is linked to that repository and inherits its visibility, and the repository here is public. So a second workflow copies the manifest across, registry-side, without pulling a byte to the runner:

docker buildx imagetools create \
  --tag ghcr.io/danieljohnmorris/cal-diy-build:v6.2.0-arm64 \
  ghcr.io/danieljohnmorris/cal.diy:v6.2.0-arm64

The copy is public. Two names for one image is not tidy, and it beats asking a human to click a dropdown every time the package name changes.

It wants 860 MB and it holds there

Measured locally before deploying, because a box with 4.2 GB free deserves a number rather than a guess. Idle after serving a few pages, then under a burst of sixty concurrent requests:

caldiytest-calcom-1     853.9MiB
caldiytest-calcom-1     863.7MiB   (under burst)
caldiytest-database-1    72.09MiB

On the box it settled at 859.8 MB against a 2 GB mem_limit, with Postgres at 70.8 MB. Available memory went from 4,252 MB to 3,295 MB. That makes cal.diy the largest single thing on the machine, ahead of Dokploy’s own 696 MB and about six times Bugsink’s 153 MB.

The limit is worth setting rather than trusting. scripts/start.sh runs prisma migrate deploy and then npx ts-node --transpile-only scripts/seed-app-store.ts on every container start, not just the first, so the startup peak is a TypeScript compile and it happens on the box. Locally the container reported healthy 45 seconds after start.

The MIT fork still sells you a licence

Step 2 of the first-run wizard is headed “Choose a license”, offers AGPLv3 against “Enter license key, starting at $15/user per month, access to all enterprise features”, and links to go.cal.com/self-hosted. This is a fork whose README says 100% MIT and no licence key required, and whose LICENSE file is MIT.

Nothing behind that step is enabled, and skipping it is one click. The enterprise code came out and the onboarding copy stayed in, which is worth knowing before you conclude you have installed the wrong thing. Two smaller versions of the same: the page title on every screen still reads “Cal.com”, and the booker’s layout switcher renders raw i18n keys, $Switch to monthly view.

ALLOWED_HOSTNAMES is not a list of hostnames you allow

With the site up, one warning repeated on every request:

orgDomains.ts  Match of WEBAPP_URL with ALLOWED_HOSTNAMES failed
{"WEBAPP_URL":"https://cal.danieljohnmorris.com","ALLOWED_HOSTNAMES":[]}

An empty list against a real URL reads like a variable somebody forgot to set, and .env.example ships it as a quoted list of Cal.com’s own domains. Setting it to '"danieljohnmorris.com"' silenced the warning and returned 404 on every public booking page.

The list is not an allowlist. It is the set of hostnames whose subdomains are treated as organisation domains, so adding the apex made cal.danieljohnmorris.com resolve as the organisation cal, and /dan/30min stopped existing. cal.diy has Organizations removed as an enterprise feature; the subdomain detection in front of it does not know that.

Reverting it brought the booking page back on the next deploy, warning and all. The comment now sitting above it in the compose file is longer than the setting was.

What working looks like

A slot on the public page, a name and an email, Confirm. The booking for that smoke test lands at /booking/ksD86ZwVngkrmStZ4bBbf9, and Resend’s API confirms its host notification left the box. Two fields from the record, subject re-wrapped here because the source has it on one line:

"subject": "[Action Required] Confirmed: 30min with Deploy Smoke Test
            at 10:00am - 10:30am, Monday, August 24, 2026"
"last_event": "delivered"

SMTP is Resend on port 587 with STARTTLS, because Hetzner blocks 465 outbound. After two further redeploys, select count(*) over Booking, users and EventType still returns 1, 1 and 3, which is the named volume doing its job.

What is not done is a backup. That Postgres volume now holds the only copy of every booking, and it sits alongside five other named volumes on the same box with nothing snapshotting any of them.