Services
A service is one deployable unit, built from a GitHub repository or pulled as a Docker image. This page covers builders, start commands, health checks, resources, replicas, cron jobs and App Sleeping.
Sources#
| Source | How it is built | Deploys on |
|---|---|---|
GitHub repository (owner/repo, branch, optional root directory) | Railpack, or the repository's Dockerfile | every push to the tracked branch, skiffly up, redeploy |
Docker image (ghcr.io/org/app:1.2.3, postgres:17) | not built, pulled by the node | manual deploy, image tag change |
Private repositories need the Skiffly GitHub App (Settings → Developer → Connect GitHub). Private registries are not supported yet: push to a public registry or build from a repository.
New service on the project canvas → GitHub repo, Docker image or Template. For a repository pick the branch. Root directory, builder, Dockerfile path and build command live under the service's Settings → Source; the port is set through the CLI, the API, MCP update-service or config as code for now.
Deploy a docker-compose project#
When the repository has a docker-compose.yml, the GitHub repo tab lists its services and offers Deploy all N services. One click creates every service in the current environment: a prebuilt image: is used as is, otherwise the service is built from its build.context with the Dockerfile from compose; services with ports: get a public domain, the rest stay on the private network. Variables are carried over with their defaults, another service's name used as a host becomes ${{<service>.SKIFFLY_PRIVATE_DOMAIN}}, Docker's DNS 127.0.0.11 becomes ${{SKIFFLY_DNS_RESOLVER}}, and keys like ORIGIN or APP_URL point at the public service's domain. Volumes become Skiffly volumes; databases without one get a volume in their data directory.
The compose file is looked up in the root and in the usual folders (docker/, deploy/, docker/production/, scripts/), and the example variables in .env.example, .env.sample, env.example, example.env and similar. Each service has a checkbox: helpers for development and tests (mailhog, adminer, e2e…) start unchecked, services with profiles are skipped like docker compose up does. Databases stay on the private network even if compose publishes their port. When the catalog has a template for the project (Umami, Plausible…), the dialog suggests it — templates come with tuned volumes and generated secrets.
Not carried over: one-off jobs (restart: "no", or a service others wait for with service_completed_successfully), volumes shared between services or mounted read-only, source-code bind mounts of services built from the repository, privileged, devices, custom networks. After the deploy a note lists what to check — values taken from .env.example and variables that had no value. Public repositories only for now.
Builds#
Railpack (default) inspects the repository and produces an image for Node, Bun, Python, Go, Rust, PHP, Ruby, Java, Elixir, static sites and more. It reads the usual files (package.json scripts, requirements.txt, go.mod, …), so most projects need no configuration.
Dockerfile: when the repository (or the root directory) contains a Dockerfile, it is used automatically. Set a different path with dockerfilePath, and force a builder with builder: RAILPACK | DOCKERFILE (serviceUpdate, skiffly config, MCP update-service).
Other build settings: build command (overrides Railpack's build step) and root directory (monorepos: the build runs inside it).
Watch paths (watchPatterns, Settings → Source → Watch paths, one glob per line) limit automatic deployments: a push to the tracked branch deploys the service only when at least one changed file matches a pattern (apps/web/**, packages/shared/**) and none of the ! exclusions (!**/*.md). Patterns are relative to the repository root; an empty list means every push. The changed files come from the push webhook, so a push with more than 20 commits or a force-push without commits always deploys. Watch paths are per environment and take effect on the next push — saving them does not redeploy.
Builds run in a Kata microVM with BuildKit and layer caching; the image lands in a registry on the node. At most two builds run at once per node; a queued build shows waiting for a build slot in its log.
Start command and port#
Railpack derives the start command (npm start, gunicorn …, the compiled binary). Override it in the service's Settings → Deploy → Start command or with startCommand; for a Docker image the start command replaces CMD.
If Railpack finds nothing to start (no start script, main or index.js in a Node project, no entry point for other languages) and no start command is set, the build fails at the plan step with No start command detected and a hint for your language, instead of deploying an image that exits right away. Add a start script or set the start command, then redeploy.
The service must listen on $PORT. Skiffly sets PORT to the service port (port on the service, 8080 when unset) and routes the public domain and the private hostname to it. Services from templates come with the right port already. If you set a PORT variable yourself, your value wins — some images use PORT for something else, such as the upstream an nginx front proxies to.
Health checks#
With a health check path (/healthz) the node probes GET <path> on the service port: a new deployment receives traffic only once the check passes, and a container that fails the check three times in a row is restarted. Without a path the probe is a TCP connect to the port. The health check timeout (healthcheckTimeout, seconds, default 300) is how long a starting container may keep failing the check — a probe every 5 s until the timeout — before the deployment is marked FAILED; raise it for slow migrations on boot. Changing the path or the timeout redeploys the service.
Resources and replicas#
Each replica gets a CPU and memory limit: 2 vCPU / 2 GiB by default, changeable in Settings → Resources within the limits of your plan. Memory is never overcommitted; CPU can burst up to the limit. Billing counts the reserved limit while the replica runs.
Replicas (numReplicas) run identical copies behind the same domain; requests are balanced across them. Volumes cannot be shared, so a service with a volume runs one replica.
skiffly api 'mutation { serviceInstanceUpdate(serviceId: "…", environmentId: "…", input: { numReplicas: 2, cpuMillis: 1000, memoryBytes: 1073741824 }) }'Restart policy#
ON_FAILURE (default, restartPolicyMaxRetries = 10), ALWAYS or NEVER (Settings → Deploy → Restart policy). Containers are always restarted by the cluster; the policy decides when Skiffly stops restarting:
ALWAYS— restart forever. A container in a crash loop (three restarts in a row) shows the deployment asCRASHEDwhile the restarts continue.ON_FAILURE— once the container has crashed more thanrestartPolicyMaxRetriestimes, the service is scaled to zero and the deployment is markedCRASHEDwithstopped after N restartsand the last log lines. During a rollout the previous deployment keeps serving. Restart or Redeploy starts it again.NEVER— the same, but after the first crash.
The restart counter is per container and resets when the container stays up. Changing the policy redeploys the service.
Cron jobs#
Set a cron schedule (0 3 * * *, UTC) on a service and it stops being a long-running process: each tick starts one container with the start command and waits for it to exit. Runs do not overlap; nextCronRunAt shows the next tick. Cron services do not get a domain and are not put to sleep.
App Sleeping#
Turn on App Sleeping (sleepApplication: true) for HTTP services that are idle most of the time. After about 10 minutes without a request through the public domain the service is scaled to zero and its deployment shows SLEEPING; the next request wakes it (typically 2–10 s, the requester waits) and traffic resumes. A sleeping service is not billed for CPU and memory; its volume is.
Sleeping needs a public domain and applies only to HTTP services without a TCP proxy; cron services and workers that only make outbound requests cannot be woken and are left alone. The setting applies immediately, without a redeploy.
Isolation runtime#
Services run in gVisor (runtimeClass: gvisor) by default. Some images need a full kernel (databases that use io_uring, anything with SO_REUSEPORT multi-threading, FUSE); switch such a service to Kata (runtimeClass: kata), a lightweight microVM. See Security.
Deleting a service#
Settings → Danger zone → Delete, skiffly service delete or serviceDelete(id). Deletion removes the workload in every environment, the domains and the volumes.