xhostddocs
Console ↗
On this page

Docker recipe: your own Dockerfile

This recipe deploys a real app from a Dockerfile that you write yourself. This guide is one half of a pair. Both guides describe the same app, and they divide the subject between them.

  • This guide owns the docker template. It tells you how to write the Dockerfile. It covers the warm base images, the image size cap, and the content of CMD. It also gives the rule that environment variables exist at run time only. It shows Dockerfile, requirements.txt and app.py.
  • The Postgres recipe owns the data layer. It covers DATABASE_URL, the psycopg driver mismatch, the alembic migrations at container start, and the automatic snapshots before a deploy. It shows alembic.ini and all the files under migrations/.

Read this guide first if the docker template is new to you. Read the other guide first if your app builds correctly and the problem is the database.

What you get

You get a FastAPI service. Your own Dockerfile builds it, and it connects to the Postgres database that xhostd gives the channel. It serves a JSON health response at /. It lists the notes at GET /notes. It makes a note at POST /notes. It marks one note done at POST /notes/{id}/done.

Alembic migrations create the schema and change it, and they run at container start. The first boot of a new app thus makes its own tables. A later deploy with a new migration applies that migration. Neither step is manual. The build takes two seconds when the layer cache is warm.

The worked example is live at recipe-docker-pg-docs.randomimity.app. Read it, but do not write to it. It is a real database behind a real write API. The note that it lists is the note that this guide's transcript made. If you obey the recipe on your own account, you get the same service under your own name.

The files

The app is recipe-docker-pg, and its template is docker. This guide shows three of its eight files. The Postgres recipe shows alembic.ini, migrations/env.py, migrations/script.py.mako and the two migrations in migrations/versions/ in full. Those files are the data layer, not the build. All eight files ship in one commit. The two guides divide the prose only, never the deploy.

Dockerfile

The docker template builds the Dockerfile at your repo root on every deploy. It then runs that image with the image's own ENTRYPOINT and CMD. The platform makes no file for you, and it puts nothing into the build.

# python:3.13-slim is a platform warm base — its layers are exempt from
# the per-plan charged image size, and it is already on the cell so the
# build starts instantly.
FROM python:3.13-slim

WORKDIR /app

# uv resolves and installs far faster than pip. Pinned to an exact
# version tag — append @sha256:<digest> if you need it immutable.
COPY --from=ghcr.io/astral-sh/uv:0.5.14 /uv /usr/local/bin/uv

COPY requirements.txt .
RUN uv pip install --system --no-cache -r requirements.txt

COPY . .

# Migrations run in the START command, never at build time. The build has
# no DATABASE_URL at all — xhostd injects env at run time only, never as
# build args — so a build-time migration cannot work even in principle.
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app:app --host 0.0.0.0 --port $XHOST_HTTP_PORT"]

Four parts of that file do real work.

The base is a warm base. Every cell already holds these eight images:

Warm base
node:22-slim
node:24-slim
node:26-slim
python:3.11-slim
python:3.12-slim
python:3.13-slim
python:3.14-slim
debian:trixie-slim

Match every FROM in your Dockerfile to this list, including a build-only stage in a multi-stage build. A stage that names a warm base starts with no pull, so the build begins at once. Any other base pulls over the network first, whether or not its layers reach the final image.

The final stage's warm-base layers are also exempt from your plan's charged size, so only the layers that you add count against the cap. That exemption applies to the final stage alone. The pull applies to every stage, so match every FROM, not only the one that ships.

A multi-stage build needs a warm base in every stage. It keeps build tools out of the shipped image, and the builder stage still pulls its own base first:

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

A cold builder base never shows up in the charged size, so the size report gives no hint that it is there. That base still costs a pull on every build that misses the cache.

COPY requirements.txt . comes before COPY . .. Docker then repeats the dependency step only when requirements.txt changes. If you copy your whole tree first, every source edit makes that layer invalid. A two-second deploy then becomes a two-minute deploy.

xhostd injects environment variables at run time only, never as build args. There is no --build-arg path, and the build can read no secret store. Your DATABASE_URL, your S3_* credentials and every variable that you set appear in one place: the container's environment at container start. That is why alembic upgrade head is in CMD and not in a RUN step. A migration at build time has no database to connect to.

CMD uses the sh -c form on purpose. The shell expands $XHOST_HTTP_PORT. The exec replaces the shell with uvicorn, so the server gets the stop signals directly. Your app needs both details. See When it goes wrong.

requirements.txt

This file pins every version exactly. Each later deploy thus builds the same image that you tested.

fastapi==0.115.6
uvicorn==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
psycopg[binary]==3.2.3

psycopg[binary] is psycopg 3. It is not a replacement for psycopg2, which is the driver name that SQLAlchemy selects by default. That mismatch is the most frequent failure on this platform. The Postgres recipe tells you how to correct it.

app.py

import os

from fastapi import FastAPI
from pydantic import BaseModel
from sqlalchemy import create_engine, text


def database_url() -> str:
    """xhostd injects DATABASE_URL with the bare ``postgresql://`` scheme.

    SQLAlchemy maps that scheme to psycopg2, which we do not ship. Name
    the driver explicitly so it loads psycopg 3 instead.
    """
    return os.environ["DATABASE_URL"].replace(
        "postgresql://", "postgresql+psycopg://", 1
    )


# pool_pre_ping discards connections the database closed while idle,
# which is what otherwise surfaces as a stray 500 after a quiet period.
engine = create_engine(database_url(), pool_pre_ping=True)

app = FastAPI()


class NoteIn(BaseModel):
    body: str


# The health check probes GET / and needs a 2xx, so / must not 404 —
# unless the app creates the file named by $XHOST_READY_FILE instead.
@app.get("/")
def root():
    with engine.connect() as conn:
        row = conn.execute(
            text("SELECT count(*) AS total, count(*) FILTER (WHERE done) AS done FROM notes")
        ).mappings().one()
    return {"ok": True, "notes": row["total"], "done": row["done"]}


@app.get("/notes")
def list_notes():
    with engine.connect() as conn:
        rows = conn.execute(
            text("SELECT id, body, done, created_at FROM notes ORDER BY id")
        ).mappings().all()
    return {"notes": [dict(r) for r in rows]}


@app.post("/notes")
def create_note(note: NoteIn):
    with engine.begin() as conn:
        new_id = conn.execute(
            text("INSERT INTO notes (body) VALUES (:body) RETURNING id"),
            {"body": note.body},
        ).scalar_one()
    return {"id": new_id}


@app.post("/notes/{note_id}/done")
def mark_done(note_id: int):
    with engine.begin() as conn:
        updated = conn.execute(
            text("UPDATE notes SET done = true WHERE id = :id"), {"id": note_id}
        ).rowcount
    return {"updated": updated}

Note the route at /. The deploy's health check probes GET / on the port that $XHOST_HTTP_PORT names, and the value is 3000. The probe needs a 2xx or 3xx answer within 120 seconds. An API with all its routes under /api fails its deploy, although the process runs correctly. The one alternative is the file that $XHOST_READY_FILE names, which the probe also accepts.

The deploy

Every app owns a git repo, and git push and then deploy is the standard path. A push sends only the diff, which keeps the second, tenth and hundredth edit cheap. Git transfers the few lines that changed, and needs no anchor to place them. A tool call carries an anchor and the new text at best, and a whole file when you send one. Use commit_files in one situation only: git is not available on the machine where you work.

Both paths keep two acts separate. A push stores your code, but it does not deploy your code. deploy is its own explicit call, and that is the point: you name the commit that goes live.

Do these four steps in order. All eight files ship in the one push.

1. Create the app. This call makes the prod channel, its hostname, its git repo and its Postgres database.

create_app(name="recipe-docker-pg", template="docker")
→ {"id": "d2ae0f30-36f0-443f-95d1-d937a7bbb676",
   "name": "recipe-docker-pg",
   "template": "docker",
   "repo_url": "https://git.randomimity.com/docs/recipe-docker-pg.git",
   "channels": [{"id": "50bdd958-d4e7-41db-8adb-9a49cd8966fd",
                 "name": "prod",
                 "hostname": "recipe-docker-pg-docs.randomimity.app",
                 "current_sha": null}], ...}

Keep the app id and the prod channel id, because every later call needs one or both. repo_url is the repo for your push. get_app returns that URL again if you lose it.

2. Make a credential. One token is your git password, your Postgres password and your platform API bearer. It is valid for 30 days.

get_credentials()
→ {"token": "xh_...", "username": "docs",
   "expires_at": "2026-08-30T17:26:27Z",
   "scopes": ["blob:*", "deploy:*", "repo:*", "db:*", "channel:*", "stats:read"]}

This recipe shows the HTTPS steps. Where a shell is available, SSH is the first git transport, because the private half of the key never enters a tool call. One registration then covers every app on that machine. The git guide holds the transport branch and the SSH commands.

Put the token in the password field of the remote URL. That detail is the important one:

https://<username>:<token>@git.randomimity.com/<username>/<app>.git

3. Clone the repo, then commit and push. A new xhostd repo is empty, and git tells you so. This warning is normal, not a fault:

$ git clone https://docs:$XHOST_TOKEN@git.randomimity.com/docs/recipe-docker-pg.git
Cloning into 'recipe-docker-pg'...
warning: You appear to have cloned an empty repository.

Write the eight files into that directory. Then commit and push them:

$ cd recipe-docker-pg
$ git add -A
$ git commit -m "notes API on the docker template"
$ git push origin master

Never put the token in a file that you commit, or in text that you paste. $XHOST_TOKEN above holds the value from get_credentials. A remote URL with a real token in it goes into .git/config, into your shell history and into the output of every git remote -v.

4. Deploy the branch.

deploy(app_name="recipe-docker-pg",
       channel="prod",
       ref="master")
→ {"deploy_id": "abf7a32e-394d-4646-b774-0c12c1c3f046",
   "channel_id": "50bdd958-d4e7-41db-8adb-9a49cd8966fd",
   "status": "queued"}

ref is a branch name, and xhostd resolves it to that branch's current head. After a push you thus do not need the sha. sha is also valid when you want an exact commit, and sha wins if you give both.

The deploy runs asynchronously. Follow it with get_deploy_log(app_name=..., channel=..., deploy_id=...). The first line of the reply states the outcome — status is one of queued, running, success, or failed. Poll while the status is queued or running, and on failed the reason is in the log tail.

Verify it

This is the deploy above, abf7a32e-394d-4646-b774-0c12c1c3f046. The reply starts with a status header, then the log. The transcript shows short ids, and it omits the buildkit lines that teach nothing.

deploy abf7a32e — success (sha aacda69971a1)
started: 2026-07-31T18:38:41Z   finished: 2026-07-31T18:38:55Z

[2026-07-31T18:38:41+00:00] deploy begin id=abf7a32e-... channel=50bdd958-... sha=aacda699...
[2026-07-31T18:38:41+00:00] git_sync ok: synced app=d2ae0f30-... channel=50bdd958-... sha=aacda699...
[2026-07-31T18:38:41+00:00] [build] start sha=aacda69971a19f0b7e37e613d21b0b361c98c1fa
[2026-07-31T18:38:41+00:00] [build] queued 0s, starting
[2026-07-31T18:38:42+00:00] [build] #7 [stage-0 1/6] FROM docker.io/library/python:3.13-slim@sha256:6771159cd4fa...
[2026-07-31T18:38:42+00:00] [build] #8 [stage-0 3/6] COPY --from=ghcr.io/astral-sh/uv:0.5.14 /uv /usr/local/bin/uv
[2026-07-31T18:38:42+00:00] [build] #8 CACHED
[2026-07-31T18:38:42+00:00] [build] #11 [stage-0 5/6] RUN uv pip install --system --no-cache -r requirements.txt
[2026-07-31T18:38:42+00:00] [build] #11 CACHED
[2026-07-31T18:38:42+00:00] [build] #12 [stage-0 6/6] COPY . .
[2026-07-31T18:38:42+00:00] [build] #12 DONE 0.0s
[2026-07-31T18:38:43+00:00] [build] finished in 2s
[2026-07-31T18:38:43+00:00] [build] queue wait 0s, build 2s
[2026-07-31T18:38:43+00:00] [build] image 262.09 MB total, 94.63 MB charged — base python:3.13-slim exempt
[2026-07-31T18:38:45+00:00] channel snapshot saved: 0.00 MB
[2026-07-31T18:38:46+00:00] start_container template=docker
[2026-07-31T18:38:46+00:00] health_check container=6f0c1f9382cb... port=3000 timeout=120.0s
[2026-07-31T18:38:50+00:00] [container] INFO  [alembic.runtime.migration] Running upgrade  -> 0001, create notes
[2026-07-31T18:38:50+00:00] [container] INFO  [alembic.runtime.migration] Running upgrade 0001 -> 0002, add done flag to notes
[2026-07-31T18:38:54+00:00] [container] INFO:     Uvicorn running on http://0.0.0.0:3000 (Press CTRL+C to quit)
[2026-07-31T18:38:54+00:00] health_check ok
[2026-07-31T18:38:54+00:00] [container] INFO:     10.77.1.5:45294 - "GET / HTTP/1.1" 200 OK
[2026-07-31T18:38:54+00:00] caddy ensure_route hostname=recipe-docker-pg-docs.randomimity.app upstream=10.77.1.5:32044
[2026-07-31T18:38:55+00:00] deploy success

Read these six lines on every deploy.

#11 CACHED, and build 2s. Docker used the cached dependency layer again, and it did not repeat the step. Only #12 COPY . ., your source, ran. That is the result of the COPY order. Docker copies requirements.txt on its own, so it builds the dependency layer again only when your dependencies change. A deploy that changes the source alone costs about two seconds.

image 262.09 MB total, 94.63 MB charged — base python:3.13-slim exempt. This is the most useful line for a docker user. Total is the size of the image on disk. Charged is the size that counts against your plan's image size cap. The platform subtracts the layers of the largest warm base under your image. Here that base is the ~167 MB difference, and the platform charges you for the 94.63 MB that you added. It subtracts one base only, and only if your image is truly built on that base.

Each plan has its own cap: basic 512 MiB, builder 2 GiB, indie 4 GiB, studio 8 GiB, pro 12 GiB.

channel snapshot saved: 0.00 MB. Every non-static deploy saves a snapshot of the channel's Postgres schema before the new container starts. You do not ask for the snapshot, and you cannot forget it. This snapshot rounds to 0.00 MB because the database is still empty on the app's first deploy. A snapshot holds the state before its own deploy, and this database holds nothing at that point. See the Postgres recipe for the calls that list and restore a snapshot.

The two Running upgrade lines. These lines are alembic upgrade head inside the container. It makes the schema from nothing on the app's first boot: -> 0001, then 0001 -> 0002, in that order. A deploy that adds a third migration prints one more line, and nothing else changes. If you see the alembic banner with no Running upgrade line, the database is already at head and alembic had no work.

health_check ... port=3000 timeout=120.0s, then health_check ok eight seconds later. In those eight seconds the migrations run, and then uvicorn binds its port. The probe gets its first 2xx after that. The 120-second window gives a migration the time to finish.

caddy ensure_route. xhostd points the hostname at the new container only after the health check passes. On a redeploy, the line that retires the previous container, stop_and_remove old container=..., comes after that. That order is the reason why a redeploy loses no requests.

Then the two-line proof, against the live demo:

$ curl -sS https://recipe-docker-pg-docs.randomimity.app/
{"ok":true,"notes":1,"done":0}

$ curl -sS https://recipe-docker-pg-docs.randomimity.app/notes
{"notes":[{"id":1,"body":"first note from the recipe","done":false,"created_at":"2026-07-31T18:39:10.159203+00:00"}]}

POST /notes wrote that note after the deploy. The Postgres recipe shows the call, and what then happened to the row.

To learn whether the app is alive, call get_runtime_log with no command:

get_runtime_log(app_name="recipe-docker-pg", channel="prod")

Without a command, the call starts no container on the host. It returns the status header alone. The header gives the process state, the exit code if the process stopped, and whether the kernel OOM-killed it. It also gives the restart count and the container indices that you can still read. This call is the fastest check for a crash.

Those indices are useful after a redeploy. The platform archives the previous container's log, so you can still read the output of the version that crashed. Give its container_index to get_runtime_log.

When it goes wrong

You expected build args to carry secrets or DATABASE_URL

They cannot. xhostd injects the environment at run time only. There is no --build-arg and no build-time secret mount. The build cannot read DATABASE_URL, because that value does not exist at build time. The symptom is a RUN step that fails on an absent variable, or an os.environ[...] KeyError in the build. Move the work into CMD.

A second rule is as important. A build can never read a secret, so never put a secret into the image yourself.

The image is over your plan's cap

The deploy fails at the build step. The message names the charged size, the cap and your plan, and the platform removes the image. Read the total/charged line first. If charged is near total, your base is not a warm base, and the platform charges you for all of it. Change FROM to one of the eight warm bases above, which usually corrects the whole problem. If charged is truly large, your runtime image holds build tools. Use a multi-stage build that copies only the artifact, and give the builder stage a warm FROM as well.

The log says a stage is not a warm base

The build log ends with a line like this:

note: stage 'build-stage' FROM node:20-slim is not a warm base — node:22-slim,
node:24-slim and node:26-slim pull instantly

The note tells you that one stage started with a network pull that it did not need. It does not fail anything. A build-only stage never reaches the charged size, so the size line above it cannot tell you this — the note is the only place it shows.

Change that stage's FROM to one of the named tags. The note names the warm tags that share the repository of the base you used, so it answers a python:3.10-slim builder with the python warm bases.

The note repeats on every deploy until the FROM changes, and it appears on a failed build too, where a cold base is one more thing to rule out.

CMD does not exec the final process

The form CMD ["sh", "-c", "alembic upgrade head && uvicorn ..."] has no exec. The shell then stays alive as PID 1, and uvicorn is its child. The stop signals go to the shell, and the shell does not send them to uvicorn. The platform thus kills the server instead of a clean stop, and every redeploy waits for the full stop timeout. Put exec before the last command in the chain.

GET / returns 404, so the deploy fails even though the app started

The health check needs a 2xx or 3xx answer from GET / on the health port within 120 seconds. As an alternative, the file that $XHOST_READY_FILE names must exist. An app that serves /api/... only, and makes no ready file, fails the deploy. Its own logs still show a correct start. Add a root route, and a JSON {"ok": true} is enough. This recipe's deploy succeeded, so its log does not hold the message below. The message has this shape, with a short container id: health check failed for container ...: no 2xx/3xx response at GET / on port 3000 and no readiness file created at $XHOST_READY_FILE within 120s.

The server listens on a hardcoded port

Bind the port that $XHOST_HTTP_PORT names, not a literal port. The platform selects the value, and the health check probes that port only. The probe cannot see a server on any other port.

This failure is easy to miss. CMD uses the sh -c form for one reason: the shell expands the variable. The pure exec form —

CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "$XHOST_HTTP_PORT"]

— expands nothing. uvicorn gets the literal 16-character string $XHOST_HTTP_PORT, and it stops on that value. Use sh -c, or read the variable in your own code.

404 and 502 from the hostname mean different things

A channel that exists but has no route returns 404 on its hostname. A channel with a route but no live server returns 502. Both codes help you. A 404 tells you that the deploy did not reach caddy ensure_route. A 502 tells you that the deploy reached it, but the container does not serve.

Single-page apps: the fallback is yours

A single-page app serves one index.html, and a client-side router interprets paths such as /dashboard. The hosts that such an app migrates from often supply a rewrite — nginx with try_files, or a platform's automatic SPA fallback — that serves index.html for every unknown path. xhostd has no such rewrite. The edge proxies every path of your hostname straight to your container, and it rewrites nothing. Your server decides what every path means, so your server must implement the fallback.

The symptom is precise. The app works while the visitor navigates inside it, because the router changes the path without a request. A refresh, a deep link, or a direct navigation to /dashboard then sends a real request for that path, and your server answers its own 404 — from FastAPI, a raw {"detail":"Not Found"}. That response looks like a routing bug in the app. It is a missing fallback in the server.

The platform sign-in flow depends on the same fallback. After the sign-in, the gateway returns the visitor with a real HTTP navigation to the path the visitor was on. If that path exists only in the client-side router, the return itself 404s, so a missing fallback also breaks login on those routes.

In FastAPI, register the API routes first, mount the built assets, and end with a catch-all that serves index.html for every other GET path:

from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

# The API routes are registered before this point. Starlette matches in
# registration order, so they win over the catch-all.
app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")


@app.get("/{path:path}")
def spa_fallback(path: str):
    return FileResponse("dist/index.html")

GET / also lands in the catch-all and serves index.html, so the health check gets its 2xx.

Enter a topic, task, or tool name.

Public documentation only · Search stays in your browser.