Skip to Content
Framework GuidesDeploy a Docker Image

Deploy a Docker image

Build the image wherever you already build images — your laptop, your CI — push it to Ghayma, and deploy it. Nothing is built on the platform: the image you pushed is the image that runs. An image deploy is an ordinary deploy — the same limits, the same deployment history and the same rollbacks as ghayma deploy.

No registry account anywhere. The push goes to Ghayma’s own registry and signs in with your Ghayma identity — there is no Docker Hub or ghcr account to create, and no registry password to store. Only the layers the registry does not already have are uploaded, so a second push of the same app is a small one.

Push and deploy

Build the image

docker build --platform linux/amd64 -t my-app .

--platform linux/amd64 matters on an Apple laptop — see the image contract below.

Push it

ghayma docker push my-app --tag v1

The CLI works out which app the image belongs to, mints a push credential for that one repository, and hands the upload to your own docker:

🐳 Pushing my-app → registry.ghayma.tech/my-app:v1 ⬆️ Uploading (only the layers the registry doesn't have)... ✅ Pushed registry.ghayma.tech/my-app:v1 digest sha256:0a1b2c… Deploy it with: ghayma deploy --image v1

Deploy it

ghayma deploy --image v1

Nothing is built. The image is checked, then rolled out as it is.

ghayma docker push runs the docker CLI — it is what built the image, and its layer-existence checks are what make a repeat push upload only what changed. A machine without it is told so before anything else happens. Its flags:

FlagWhat it does
--siteSite whose repository to push to (name or slug)
--tagTag to push as (default: the local image’s tag, else latest)
--deployDeploy the image once it is pushed
--prodWith --deploy, deploy to production

The credential the push logs in with is minted for that one repository, expires in about 30 minutes, and is dropped again when the push finishes — whether it succeeded or not. Nothing to rotate and nothing left behind.

See ghayma docker push for the full command reference.

Pushing before your first deploy

A push does not need a site. A project created with no site — or one you have never deployed — still has a repository waiting at its own name, so you can push an image first and decide what to do with it after:

✅ Pushed registry.ghayma.tech/my-app:v1 digest sha256:0a1b2c… ℹ️ This project has no site yet, so there is nothing to deploy the image to. Create one with: ghayma site create main Then deploy it with: ghayma deploy --image v1

Deploying, on the other hand, replaces what an app is serving, so it needs one. ghayma deploy --image on a site-less project says which two ways there are to get a site, rather than picking one for you:

ℹ️ This project has no site yet, and deploying an image needs one. Create it with: ghayma site create main (or run 'ghayma deploy' once to deploy from source, which creates the main site)

ghayma docker push --deploy says the same thing — before the upload, not after it.

The image contract

Five things are asked of the image. Four of them are checked before anything is scheduled, and a deploy that fails one of them fails with the reason in its build log; the fifth is your app answering its health check.

  • Built for linux/amd64. Every Ghayma node is linux/amd64, and a plain docker build on an Apple Silicon machine produces arm64. Pass --platform linux/amd64, or push a multi-arch image that contains an linux/amd64 variant.

  • A numeric, non-root USER. USER 1000, USER 101, USER 65532 — a number, not a name. No USER at all, USER root and USER 0 are refused, and so is USER app: the platform enforces “never as root” through the kubelet, which can only verify a uid. Unlike an image Ghayma builds for you, your image’s own uid is what runs — so nginxinc/nginx-unprivileged (uid 101) works as it is, and nothing re-owns the paths your Dockerfile chowned.

  • Listens on $PORT, bound to 0.0.0.0. PORT is injected from the site’s port setting (default 3000). Bind the interface, not localhost — a server on 127.0.0.1 is unreachable from outside its own container. If your image declares one EXPOSEd port and the site has no port set, that port is adopted as the site’s automatically; two EXPOSEs are ambiguous and neither is taken, so set the port yourself in the site’s build settings.

  • Answers GET on the health path with a 2xx or 3xx. The default path is /, changeable per app under the site’s Settings → Health path. The readiness check starts 10 seconds in and repeats every 5; until it passes, the rollout does not finish and the previous build keeps serving.

  • Within your plan’s maximum image size. Measured exactly — the config and layers of the linux/amd64 variant — against the app tier’s ceiling. See Pricing by service.

NODE_ENV=production and HOSTNAME=0.0.0.0 are injected into every app pod, image deploys included. Harmless for a non-Node image, and what a Node one usually wants.

What a deploy does with your tag

--image takes a tag, or a sha256: digest for one exact image:

ghayma deploy --image v1 ghayma deploy --image v1 --site dev ghayma deploy --image sha256:0a1b2c…

An image deploy is a production one when the site it targets is a production site, like every other deploy; --prod is accepted and no longer decides anything.

The tag is resolved to a digest at admission, and that digest is what is rolled out, recorded and rolled back to. Your tag can move afterwards; the deployment doesn’t. Push a new image over :v1 tomorrow and yesterday’s deployment still points at yesterday’s bytes.

By the same rule, deploying v1 twice creates two deployments and deliberately re-resolves the tag each time — so the history says what actually ran, and never what the tag happens to mean today.

When it is accepted, the deployment’s build log opens with exactly what was admitted:

Image my-app:v1 @sha256:0a1b2c… (142 MB, linux/amd64) accepted

and the deployment appears on the Deployments tab with an image badge, the repository:tag it resolved from, and the short digest that ran.

Everything else about it is an ordinary deploy: it waits its turn in the build queue, it counts against your plan’s daily deployment limit, a refused or failed image leaves the previous build serving, and you can roll back to it or from it — including back and forth between an image deploy and a source deploy.

Environment variables are still yours

Runtime environment variables work exactly as on every other deploy: the site’s own variables, plus the credentials the platform derives for you — databases, buckets, auth apps, cron secrets — are assembled and injected at deploy time. See Environment Variables.

Build-time variables are not. A variable marked build-time is read by Ghayma’s builder while it builds your source, and an image deploy has no build. Anything baked into a bundle — NEXT_PUBLIC_*, VITE_*, and their equivalents — is decided on the machine that ran docker build, so pass it there:

docker build --platform linux/amd64 \ --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \ -t my-app .

Setting such a variable on the site changes nothing about an image that was already built with (or without) it.

Cron jobs are not synced

The crons array lives in .ghayma.json and is applied by ghayma deploy reading that file on your machine. An image deploy carries no .ghayma.json, so it never adds, changes or removes a cron job — exactly like a git deploy.

Cron jobs you already have keep running, and keep hitting the newly deployed image with their CRON_SECRET_* signatures as before. To change the schedule list itself, run ghayma deploy from the source directory. See Cron Jobs.

From the dashboard

The site’s Deployments tab carries a Pushed images panel listing what you have pushed to that app’s repository — the tag, its digest, its size, when the image was built, and whether a deployment already used it — with a Deploy button per tag. Ghayma’s own internal deployment tags are not listed; they are not something anyone would choose.

How long a pushed image is kept

A tag whose digest some deployment used is kept for as long as that deployment is. A tag you pushed and never deployed is kept for 30 days and then pruned, so abandoned pushes do not accumulate against the platform forever. Push it again whenever you want it back.

When a deploy is refused

An image that fails a check is a failed deployment whose build log says why, in these words:

What you seeWhat to do
This image has no linux/amd64 variant, and every Ghayma node is linux/amd64. Rebuild it with docker build --platform linux/amd64 and push again.Rebuild with --platform linux/amd64 (or build a multi-arch image) and push again. A plain docker build on Apple Silicon is the usual cause
This image is linux/arm64, and every Ghayma node is linux/amd64. Rebuild it with docker build --platform linux/amd64 and push again.The same fix — the message names what you actually pushed. A plain docker build on Apple Silicon lands here
This image runs as root. Declare a numeric non-root UID in your Dockerfile's USER (for example USER 1000) and push again — Ghayma never runs an app pod as root.Add a USER line with a number. No USER, USER root and USER 0 all land here
This image declares a named user, which the kubelet cannot verify as non-root. Declare a numeric UID in your Dockerfile's USER (for example USER 1000) and push again.Replace USER app with the uid that user has — USER 1000
image exceeds tier limit; store large assets in object storage (image 412MB > tier s limit 350MB)Slim the image, or move the bulk into object storage. Multi-stage builds and a smaller base are the fastest wins

And for the push itself:

What you seeWhat to check
'ghayma docker push' runs the docker CLI, and no 'docker' was found on PATH.Install Docker, or start Docker Desktop. The push is docker’s own upload; there is no daemon-free mode yet
A push that stops part-way with an authentication errorThe push credential lasts about 30 minutes. Run the same ghayma docker push again — it mints a fresh one, and the layers that already arrived are not re-uploaded
could not tag my-app: …The local image name is wrong. docker images shows what you actually built
A deploy that stays deploying and then failsReadiness never passed. Check that the app binds $PORT on 0.0.0.0, and that GET on the site’s Health path answers 2xx/3xx — an app that answers 404 at / needs its health path set to something it serves

A refusal happens before anything runs, and a failed deploy of any kind leaves the previous build serving. Nothing you push can take a live app down by being wrong.