Recipe: Migrate a Node SSR app from Vercel to Datum Compute
Move a Node server-side-rendered app from Vercel to a Datum Compute workload on
the general-purpose runtime.
Based on a real migration of the datum.net website to Compute. Steps marked
(verified) were tested there. Steps marked (untested) are Vercel-specific
advice that the migration did not cover. Commands match the current Compute,
ALB and datumctl docs.
A human-readable version lives in the docs: https://www.datum.net/docs/guides/migrate-from-vercel
Audience: engineers who run an app on Vercel today. Result: your app runs as a container on Datum Compute, with secrets, a public URL, a rollback path and CI deploys.
1. How Vercel concepts map to Datum Compute
| Vercel | Datum Compute |
|---|---|
| Project | Datum project, plus one workload per app and environment |
| Build on push (Vercel builds) | You build and push a container image (for example in GitHub Actions) |
| Deployment | A workload rollout to a new image tag |
| Environment variables | A Kubernetes-style Secret, imported with --env-from-secret |
vercel.app URL | *.datumproxy.net URL, printed by deploy |
| Custom domain | datumctl alb hostname add on the workload's load balancer (this changes DNS) |
| Instant rollback | Deploy an older image tag |
| Preview deployments per branch | Not available yet |
| Password protection | Basic authentication on the whole load balancer (datumctl alb auth set) |
Serverless / Edge Functions, ISR, Cron, Image Optimization, BotID, Runtime Cache, vercel.json headers | No platform equivalent. See "Vercel-specific features: what to replace" for what to do with each. ISR and Edge Functions are (untested) |
2. Before you start
datumctlwith thecomputeplugin, logged in:datumctl auth list.- A Datum project with Compute enabled (step 4).
- An app that runs as a long-lived Node server. Next.js apps need a server
build (for example
output: "standalone"), not a Vercel-only build output (untested). - Somewhere to push images. The migration used a public GHCR package, so no registry login was needed. Private registries are (untested).
3. Containerize the app (untested for your app; the pattern is verified)
Vercel builds for you. On Compute you provide the image. A minimal example for a Node server, adjust to your framework:
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=4321
ENV HOST="::"
# Copy only what the server needs at runtime: the build output, the pruned
# node_modules and package.json. Adjust the paths to your framework.
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/package.json ./
USER node
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]The CMD is the server entry your framework's Node build produces (here, the
standalone output of Astro's Node adapter). Use your own. If npm ci runs
git-hook installers such as husky as a postinstall, disable them in the
build stage (for example ENV HUSKY=0).
Two rules matter:
- Listen on
HOST=::, not0.0.0.0. Datum networks are IPv6-only. An app bound to IPv4 starts but never answers. Make your server readHOSTat runtime, setENV HOST="::"in the Dockerfile, and also pass--env="HOST=::"at deploy time. (verified) - Pick one port and pass it as
--http-port. The edge terminates TLS, so the app serves plain HTTP.
Exclude .env* from the image with a .dockerignore. Secrets must only arrive
at runtime from the Secret.
Build and push, tagging every build with a unique, immutable tag. On Apple
Silicon, build for linux/amd64 explicitly. This was done as a precaution;
whether Datum rejects an arm64 image was not tested:
docker buildx build --platform linux/amd64 \
-t ghcr.io/<org>/<app>:v1.0.0 --push .Do not reuse a tag. Datum does not replace an instance when the image tag is unchanged, so a moved tag silently does nothing. (verified)
Registry notes (GHCR) (verified)
-
For a private registry, store the credentials in a Secret and reference it in
imagePullSecrets. See https://www.datum.net/docs/compute/configuration#use-images-from-a-private-registry. The notes below apply to the public-package route. -
A new GHCR package is private. Datum pulls without credentials, so make it public. Visibility can only be changed in the GitHub UI (package settings → Danger Zone → Change visibility).
ghand the REST API cannot do it. -
A workflow's built-in
GITHUB_TOKENcan only push packages under the repo's own org. To push to a different org you need a personal access token stored as a secret. -
Check that the image really pulls anonymously before you deploy:
T=$(curl -s "https://ghcr.io/token?scope=repository:<org>/<app>:pull" \ | python3 -I -c 'import sys,json;print(json.load(sys.stdin)["token"])') curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $T" \ -H "Accept: application/vnd.oci.image.index.v1+json" \ https://ghcr.io/v2/<org>/<app>/manifests/v1.0.0 # expect 200
4. Enable Compute
You do not request access. Compute turns on the first time you run a
datumctl compute command in the project. Check the state:
datumctl compute access --project <project> # Access: Not requested, Processing or ActiveTo enable it without running another command, run
datumctl compute access request --project <project>. Wait for Active, which
normally takes a few seconds.
On a brand-new project the locations can show up a few minutes later. Until
then a deploy fails with Unsupported value: "us-central-1". Wait until this
lists locations:
datumctl get locations --project <project>Listed is not the same as deployable. get locations can list locations
(for example de-central-1 or jp-east-1) that a workload does not accept yet.
Workloads run in us-central-1 (Dallas), us-east-1 (Ashburn) and us-west-1
(San Jose). A deploy to an unsupported location fails with
Unsupported value: "<location>": supported values: ..., and that message is
the reliable list, so use it. Pick the supported location closest to your
users. (verified)
5. Move your environment variables into a Secret
-
Export the variables from Vercel:
vercel env pull .env.production --environment=production(or copy them from the dashboard). Keep this file outside the repo and never paste it in chat.Vercel never exports sensitive variables. For variables of type Sensitive,
vercel env pullwrites the literal text[SENSITIVE](not an empty value, not an error). It is printable text, so it passes the strict check below, so it ends up in the Secret as a real-looking value. A placeholder in an auth secret such as a cron or webhook secret is a known, guessable string. Get those values from the original provider (or a local.env.local), or generate a new one where only your own app has to match it (then set the same value wherever the caller needs it). Never leave the placeholder in place.vercel env lsshows which variables are Sensitive. (verified) -
Drop Vercel-only variables such as
VERCEL,VERCEL_URLandVERCEL_ENV. If your code reads them, replace them with your own variables. (untested) -
Create a Secret in the
defaultnamespace of the project, for example<app>-secrets, with one key per variable.
Create the Secret from the CLI, not the console form
The console form can store values as raw bytes instead of text. In the
migration, six keys then held control characters, the instance failed with
ConfigurationError, and the message only said "configuration error".
(verified)
Read values from your local file and write the Secret with datumctl apply:
datumctl get secret <app>-secrets -n default --project <project> -o json \
| python3 -I -c '<set data[KEY] = base64(value) from your env file>' \
> secret.json
datumctl apply -f secret.json --project <project> --dry-run=server
datumctl apply -f secret.json --project <project>
rm secret.jsonThe warning about a missing last-applied-configuration annotation is harmless.
For a Secret that does not exist yet, apply a manifest you build locally
(apiVersion: v1, kind: Secret, type: Opaque, data with base64 values).
To change single keys later, use a merge patch, then roll the workload
(section 11): (verified)
datumctl patch secret <app>-secrets -n default --project <project> \
--type merge --patch-file patch.json # {"data": {"KEY": "<base64>"}}
rm patch.jsonKeep manifests and patch files outside the repo and delete them straight away. Print key names and lengths when you check them, never values.
Validate the Secret strictly
Run this after every change. Do not decode with errors="ignore", because it
hides corrupt bytes.
datumctl get secret <app>-secrets -n default --project <project> -o json \
| python3 -I -c '
import sys, json, base64
bad = []
for k, v in json.load(sys.stdin)["data"].items():
b = base64.b64decode(v)
# PEM keys contain newlines; every other value must be single-line text.
if not b.isascii() or not all(c.isprintable() or c == "\n" for c in b.decode()):
bad.append(k)
print("invalid keys:", bad or "none")'Also check for Vercel placeholders. This prints key names only, never values:
datumctl get secret <app>-secrets -n default --project <project> -o json \
| python3 -I -c '
import sys, json, base64
bad = [k for k, v in json.load(sys.stdin)["data"].items()
if "SENSITIVE" in base64.b64decode(v).decode().upper()]
print("placeholder keys:", bad or "none")'6. Deploy
Run this from a folder outside your repo. The command writes
workload.yaml into the current folder, and it must not be committed.
(verified)
datumctl compute deploy <app>-gp \
--project <project> \
--image=ghcr.io/<org>/<app>:v1.0.0 \
--location=us-central-1 \
--http-port=4321 \
--instance-type=datumcloud/d1-standard-2 \
--runtime-class=general-purpose \
--env="HOST=::" \
--env-from-secret=<app>-secrets \
--yes- If it says
Network "default" does not exist, the deploy creates it. - The last line prints the public URL.
- Use
general-purposefor a normal container image. The unikernel runtime needsdatumctl compute buildinstead ofdocker build, and it was unstable for one service in the migration's tests. - A workload keeps its runtime class. To change it, deploy under a new name and destroy the old one.
7. Verify before you switch traffic
datumctl get workload <app>-gp -n default --project <project> # AVAILABLE True
datumctl compute instances --project <project> # list instancesRead the app's output with datumctl compute logs <app>-gp (add --follow to
stream it) and watch a rollout with datumctl compute rollout <app>-gp. If
logs is an unknown command, run datumctl plugin upgrade compute.
While a rollout is in progress the edge returns an HTML error page ("Datum AI
Edge") instead of your app. Poll /api/health until it returns 200 before
you judge the result.
Then request the site. A new hostname may not resolve on your machine yet
(Could not resolve host). Look it up through 1.1.1.1 and pin it:
H=<hostname>.datumproxy.net
IP=$(dig +short A $H @1.1.1.1 | head -1)
curl -s -o /dev/null -w "%{http_code}\n" --resolve "$H:443:$IP" https://$H/Compare it with Vercel page by page: key routes, data from your CMS or API, forms, auth, and anything that calls a third party. The migration did not test login-protected pages, reCAPTCHA, or email forms, so test them yourself.
8. Cut over the custom domain
-
Check what the name points to today. Attaching a hostname creates a managed
ALIASrecord in your zone and takes over that name. (verified) -
Attach the hostname:
datumctl alb hostname add <app>-gp www.example.com --project <project> datumctl alb describe <app>-gp --project <project>Domain verification and DNS steps are in https://www.datum.net/docs/alb/operations/custom-domains.
-
Keep the Vercel project until you are sure. Rolling back DNS is your fallback. (untested: DNS TTL and apex-domain behavior)
-
If your app builds absolute URLs, sitemaps or canonical links at build time, build the image with the final domain. The migration image did not, so its canonical URL did not match the Compute hostname.
-
Keep search indexing off on the
*.datumproxy.netURL, or the staging copy gets indexed as a duplicate of your real site. If your app has an "allow indexing" flag, set it to false in the Secret and flip it (and roll the workload) at cutover. A flag that is a typed boolean needs an explicitfalse, not a blank value. -
Do not swap the framework adapter on a branch that Vercel deploys. Vercel builds from your Git branch. If the migration changes the adapter (for example
@astrojs/vercelto@astrojs/node), merging it to the branch Vercel watches breaks Vercel, and with it your rollback path. Keep the migration on a separate branch until DNS has moved and you are sure.
9. Deploy from GitHub Actions (replaces Vercel's Git integration)
Vercel deploys on every push. On Compute, build and push the image, then roll
the workload with datumctl compute deploy. The datum.net workflow does this:
- Push to
maindeploys staging with tagv0.0.0-<short sha>. - A published release deploys production with the release tag.
- It installs
datumctland thecomputeplugin, logs in as a service account, checks the Secret strictly, deploys withHOST=::, and confirms the workload reports the intended image. - It never runs
destroy, so the URL and basic auth stay.
One-time setup:
- Create GitHub environments, for example
datum-computeanddatum-compute-staging. - Create a Datum service account with edit access to the project. Save its
credentials JSON as the environment secret
DATUM_SA_CREDENTIALS. Do not commit the file. - Create the Secret for each target and make sure it passes the strict check.
Use DEPLOY_REV=<run id> (a "force rollout" option) after you change a Secret,
because instances read the Secret only at start.
Notes on the CI setup:
- The service account logs in with
datumctl login --credentials ./key.json(no browser, no prompts). Delete the key file right after login. - A
workflow_dispatchworkflow only appears and can only be dispatched once the workflow file exists on the repo's default branch. You cannot test it from a feature branch alone. For the first image, build and push locally (section 3) and deploy by hand. - A scheduled job replaces Vercel Cron. Datum Compute has no scheduler, so use
a GitHub Actions
schedulethat calls the protected endpoint with a bearer secret. (untested end to end) - Untested: installing
datumctland the compute plugin on a CI runner (for examplebrew install datum-cloud/tap/datumctlthendatumctl plugin install compute), and a full run as a service account.
10. Roll back
Redeploy the last good tag. The image is already in the registry, so nothing is rebuilt:
datumctl compute deploy <app>-gp --project <project> \
--image=ghcr.io/<org>/<app>:v0.9.0 --location=us-central-1 --yes- When you pass
--image, also pass--location. - Do not re-tag or cherry-pick to roll back. Treat tags as immutable.
- To fix the bug for good,
git revertthe change and publish a new, higher version.
11. Rolling after a Secret change
Instances read the Secret only at start. A redeploy with the same image and settings does nothing, so restart the workload after you change a Secret:
datumctl compute restart <app>-gp --project <project>
datumctl compute rollout <app>-gp --project <project> # watch it finishA failed instance blocks the rollout, because placement is OrderedReady. If
the old instance is stuck in ConfigurationError, the rollout stays Pending
and you cannot delete the instance (Forbidden). Fix the Secret, then
datumctl compute destroy <app>-gp --yes and deploy again. That changes the
URL and removes basic auth. (verified)
Basic authentication (replaces Vercel password protection)
datumctl alb auth set <app>-gp --user admin --password-stdin (or the console
dialog Edit Basic Authentication) protects the whole load balancer. To
protect a single route, use a SecurityPolicy. See
https://www.datum.net/docs/alb/authentication/basic-auth. Be aware:
- There are no path exceptions.
/livezand/readyzalso return401from the public URL. The backend stays healthy, because the platform checks health internally. - Webhooks that call the URL get
401. Do not point a webhook at it while auth is on. destroyremoves the URL, so you set up basic auth again after a redeploy under a new URL. Roll in place instead.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Unsupported value: "us-central-1" on deploy | The project's locations are not provisioned yet | Wait; datumctl get locations must list them |
ConfigurationError on the instance | A Secret value is not valid text | Run the strict check, fix the key, destroy and redeploy |
Rollout stays Pending after a fix | OrderedReady waits for the failed instance | Destroy and redeploy |
Forbidden when deleting an instance | Users cannot delete instances | Destroy the workload instead |
| App starts, never answers | Server bound to 0.0.0.0 instead of :: | Redeploy with --env="HOST=::" |
Could not resolve host for a new *.datumproxy.net | Local resolver cache | Resolve through 1.1.1.1 and use --resolve |
401 on every URL | Basic authentication is on | Send credentials, or turn it off in the console |
| Secret change has no effect | Instances read the Secret only at start | Run datumctl compute restart (section 11) |
| Redeploy of the same tag does nothing | Datum does not replace an instance for an unchanged tag | Use a new tag |
Unsupported value: "de-central-1": supported values | get locations lists more than workloads accept | Use one of the supported values in the error |
Secret has [SENSITIVE] values | vercel env pull cannot export sensitive variables | Get real values elsewhere; run the placeholder check |
| Image will not pull | GHCR package is private | Make it public in the GitHub UI, or configure imagePullSecrets |
| HTML "Datum AI Edge" error page right after deploy | Rollout still in progress | Poll /api/health until 200 |
| Pages return 404 after adding response headers | A header value has a non-ASCII character | Keep header values ASCII |
Vercel-specific features: what to replace
Check each of these that your app uses. Examples use Astro and Node; the pattern applies to other frameworks. (verified) unless noted.
| Feature on Vercel | What to do on Datum Compute |
|---|---|
| Framework adapter or build output | Switch to a Node server build. For example, replace Astro's Vercel adapter with @astrojs/node in standalone mode (it also serves the built static files). Check the adapter's minimum framework version. |
vercel.json headers, CSP | They stop applying. Move them into app middleware. Static files (served before middleware) do not get them, so only HTML and API responses are covered. |
vercel.json rewrites | Gone. The BotID proxy rewrites are not needed once BotID is removed. |
| Header values | Must be ASCII (Latin-1). One non-Latin-1 character (an em dash in a Link header) made Headers.set() throw and turned two pages into 404s. |
| Vercel Cron | Replace with a GitHub Actions schedule calling the endpoint (see section 9). |
| BotID | Works only behind Vercel's edge. It cannot work from a container. Replace it, for example with a honeypot, a minimum-fill-time check and a per-IP rate limit. |
Runtime Cache (@vercel/functions) | Replace with another cache driver. An in-memory driver is fine for one replica only: invalidation reaches just the instance that receives it. Use a shared store before scaling out. |
| Image Optimization | The Astro sharp image service works in the container. |
VERCEL_* variables | Drop them and read your own variables or the request hostname. |
Client IP for rate limiting. The Datum edge appends the real client IP as the
rightmost X-Forwarded-For entry; leftmost entries are client-controlled.
clientAddress in the framework is an internal fd42: address, so do not use
it. Verified by sending spoofed X-Forwarded-For values, which did not bypass
the limit. Note that a client with several egress IPs is counted per IP.
Known gaps to plan for
- No automatic preview URL per branch.
- No Redis in the migration: one replica, local cache fallback.
- Vercel-managed features (Edge/Serverless Functions, ISR and its cache, Cron Jobs, Image Optimization, Analytics, Web Vitals) have no direct equivalent in the migration. Check each one your app uses. See "Vercel-specific features: what to replace" for the ones that came up in a real migration. ISR, Analytics and Web Vitals were not covered.
- Workload locations are limited to the
us-*values for now, so users in Europe or Asia get higher latency. - A rollout after a Secret change (section 11) and DNS cutover have not been exercised end to end.
- Use scoped tokens in the Secret, not personal ones, before real use.
- The full CI workflow was confirmed with a previous project and service account. A run with the current one was still to be confirmed.
Cleanup
datumctl compute destroy <app>-gp --project <project> --yes # instances and URL; the Secret stays
rm workload.yaml # generated file, never commit it