Docker
Deploy from a Dockerfile or a prebuilt image on Skiffly — when the Dockerfile is used, the build context and `dockerfilePath`, build args and BuildKit secrets, `PORT`, `CMD` versus the start command, health checks, users, volumes and the gVisor/Kata runtimes.
Two ways to run a container image on Skiffly:
| Source | How it is built | Deploys on |
|---|---|---|
Repository with a Dockerfile | Skiffly builds it with BuildKit in a Kata microVM, pushes the image to the node's registry | every push, skiffly up, redeploy |
Image (ghcr.io/acme/api:1.4, nginx:1.27) | not built; the node pulls it | skiffly deploy --image, a tag change, redeploy |
Private registries are not supported yet: push to a public registry (GHCR public packages, Docker Hub) or build from the repository.
When the Dockerfile is used#
A Dockerfile in the repository root — or in the service's root directory — switches the builder from Railpack to Dockerfile automatically. To force one or the other set builder: RAILPACK | DOCKERFILE; to use a file with another name or location set dockerfilePath (docker/Dockerfile.prod, resolved inside the root directory first, then from the repository root). The build context is the root directory, so COPY paths are relative to it; a monorepo Dockerfile that needs the whole repository keeps the root directory at / and uses dockerfilePath: apps/api/Dockerfile.
skiffly api 'mutation { serviceUpdate(id: "…", input: { builder: DOCKERFILE, dockerfilePath: "docker/Dockerfile.prod" }) { id } }'or in config as code: build: { builder: "DOCKERFILE", dockerfilePath: "docker/Dockerfile.prod" }. The dashboard shows the builder under Settings → Build; MCP update-service takes the same fields. .dockerignore is honoured.
Build-time variables and secrets#
The build runs with none of your variables in its environment by default. Two mechanisms:
- Build args — list the variable names in a service variable
SKIFFLY_BUILD_ARGS=NODE_ENV,NEXT_PUBLIC_API_URL; those are passed as--build-argand must be declared withARGin the Dockerfile. Build args end up in the image history, so keep secrets out of this list. - Secrets — every service variable is available as a BuildKit secret with the variable's name:
RUN --mount=type=secret,id=NPM_TOKEN \
NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) npm ciThe build log says which build args were passed ([build] build-args: …). Builds have internet access but cannot reach your project's private network: nothing in the Dockerfile can talk to your database.
Port#
Skiffly sets PORT in the container and routes traffic to the service port (8080 when the service does not set one). EXPOSE is informational only. Either make the process read PORT or set the service port to what the image listens on:
ENV PORT=8080
CMD ["sh", "-c", "./server --port $PORT"] # shell form so $PORT expandsFor an image that listens on a fixed port (nginx on 80, ghost on 2368): skiffly deploy --image ghost:5 --port 2368, or Settings → Networking → Port. Listen on 0.0.0.0, not 127.0.0.1.
CMD, ENTRYPOINT and the start command#
The image's ENTRYPOINT/CMD run as usual. A start command on the service replaces CMD (the ENTRYPOINT is kept), so sh -c "npm run migrate && node server.js" works with an image whose entrypoint is docker-entrypoint.sh. There is no release phase; see the migration section of your language page, or run one-offs with skiffly ssh -- <cmd>.
Health check#
HEALTHCHECK in the Dockerfile is ignored. Set a healthcheck path on the service instead; without one the node only checks that the port accepts TCP connections. Distroless and scratch images work (no shell needed for the probe, it is HTTP/TCP from the node), but then skiffly ssh has nothing to run and a start command with sh -c fails — keep sh in the image if you need either.
Users, filesystem and volumes#
- Containers run as the user the image declares; running as root works but non-root is better (
USER node). The root filesystem is writable and ephemeral. - Volumes mount at a path (
skiffly volume add --mount-path /data) and are owned by root on first mount; a non-root process needs the directorychowned — do it in an entrypoint script, or run as root for the first start. - Images that expect Docker-specific features (
--privileged,/var/run/docker.sock, host networking) do not run.
Runtime: gVisor or Kata#
Services run under gVisor by default. Images that need a full kernel — databases relying on io_uring, FUSE, SO_REUSEPORT tricks, some JIT runtimes — should use Kata (runtimeClass: kata, Settings → Runtime). Symptoms of the wrong runtime are operation not permitted/function not implemented errors at startup. Details in Security.
Multi-stage example#
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=NPM_TOKEN \
NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || true) npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json .
USER node
CMD ["node", "dist/server.js"]The image listens on PORT in dist/server.js; set the healthcheck path to /healthz. Layer caching is kept per service between builds, so put COPY package*.json before COPY . ..
Common problems#
| Symptom | Fix |
|---|---|
| Railpack runs although there is a Dockerfile | the file is outside the service's root directory, or builder is pinned to RAILPACK |
failed to solve: … "/app/dist": not found | the build context is the root directory; check COPY paths and .dockerignore |
ARG is empty during the build | add the variable name to SKIFFLY_BUILD_ARGS |
ImagePullBackOff in the deployment message | image name/tag typo, or a private registry (not supported) |
FAILED after 5 minutes | the process listens on another port than PORT/the service port, or on 127.0.0.1 |
permission denied writing to a volume | the mount is owned by root: chown in an entrypoint or run as root |
function not implemented, io_uring_setup errors | switch the service to the Kata runtime |
exec: "sh": not found with a start command | distroless image: set the start command as the executable itself, without sh -c |
Landing: /deploy/docker