Services
Define how your code runs and how it connects to everything else, in development and production.
Services define how to run your code and connect it to other parts of your infrastructure, both in production and in local development. All configuration reaches your app through environment variables, and the same specific.hcl drives specific dev locally and specific deploy in production.
A typical setup with a public frontend and API:
build "web" {
base = "node"
command = "npm run build"
}
service "web" {
build = build.web
command = "npm start"
endpoint {
public = true
}
env = {
PORT = port
API_URL = "https://${service.api.public_url}"
}
dev {
env = {
API_URL = "http://${service.api.public_url}"
}
}
}
build "api" {
base = "go"
command = "go build -o api"
}
service "api" {
build = build.api
command = "./api"
endpoint {
public = true
}
env = {
PORT = port
}
}
command- command to start the server.root- working directory for service commands, relative tospecific.hcl. Defaults to the referenced build’sroot, or"."if no build.endpoint- defines a network endpoint (see below).port- reference to the auto-assigned port; pass it to your server.
See Builds for the build block.
Endpoints
Endpoints define how a service is accessible over the network.
Single endpoint
service "api" {
build = build.api
command = "./api"
endpoint {
public = true
}
env = {
PORT = port
}
}
public = truemakes the endpoint publicly accessible via HTTPS.- Omitting
public(orpublic = false) keeps the endpoint internal-only.
Public services are served from a managed *.spcf.app URL by default. To attach your own domain, see Custom domains.
Multiple named endpoints
A service can expose multiple endpoints on different ports, each independently public or internal:
service "api" {
build = build.api
command = "./api"
endpoint "main" {
public = true
}
endpoint "admin" {}
env = {
MAIN_PORT = endpoint.main.port
ADMIN_PORT = endpoint.admin.port
}
}
With multiple endpoints, use endpoint.<name>.port instead of port to reference each endpoint’s port.
Implicit endpoints
If a service uses port in its env vars but has no explicit endpoint blocks, an implicit internal endpoint is created:
service "worker" {
build = build.worker
command = "./worker"
env = {
PORT = port # Creates an implicit internal endpoint
}
}
Health checks
Add a health_check block inside an endpoint to tell Specific how to verify the service is healthy. The platform sends an HTTP GET to the given path on the endpoint’s port; a 2xx response means healthy.
service "api" {
build = build.api
command = "./api"
endpoint {
public = true
health_check {
path = "/healthz"
}
}
env = {
PORT = port
}
}
path- the HTTP path to probe (required, must start with/).- Keep the handler cheap: don’t query the database or call other services. A constant
200 OKis sufficient. If your service already serves a root path that always returns2xx(for example, a static site), pointhealth_checkat/. - Only one
health_checkper service is used. If multiple endpoints declare one, the first one wins. - Background workers (services without endpoints) don’t support
health_checkyet - only HTTP-serving endpoints do.
In production, health checks control rollouts and restarts:
- During a deploy, the new instance doesn’t receive traffic until its health check passes. The old version stays up until then, so users never hit a half-started process.
- If the health check starts failing after the deploy is live, the service is restarted automatically. Traffic is drained from the unhealthy instance before the restart.
- If the health check never passes during a deploy (typically a misconfigured
path), the deploy fails fast with ahealth_check_failederror pointing at the probe URL.
Connecting services
Private communication
Services reference each other’s endpoints with private_url to communicate over a private network:
service "worker" {
build = build.worker
command = "./worker"
env = {
API_URL = service.api.private_url
}
}
Available service reference attributes:
| Attribute | Description |
|---|---|
service.<name>.private_url |
Internal URL without scheme (for example, localhost:3001 in dev). Only reachable by other services. |
service.<name>.host |
Host only. |
service.<name>.port |
Port only. |
service.<name>.public_url |
Public URL without scheme (my-app.spcf.app in production, localhost:3001 in dev). Only available for endpoints with public = true. |
For services with multiple named endpoints, specify the endpoint: service.api.endpoint.main.private_url.
Public communication
Private URLs aren’t reachable from a browser. When a frontend needs to call a backend, the backend must expose a public endpoint, and the frontend references it with public_url:
service "api" {
build = build.api
command = "./api"
endpoint {
public = true
}
env = {
PORT = port
CORS_ORIGIN = "https://${service.web.public_url}"
}
dev {
env = {
CORS_ORIGIN = "http://${service.web.public_url}"
}
}
}
Environment variables
All connection details (database URLs, storage credentials, secrets) are injected as environment variables. The same references resolve to the correct values in both local development and production.
service "api" {
build = build.api
command = "./api"
endpoint {
public = true
}
env = {
PORT = port
NODE_ENV = "production"
DATABASE_URL = postgres.main.url
}
}
String interpolation
Embed references inside strings to compose values:
service "worker" {
build = build.worker
command = "./worker"
env = {
QUERY_URL = "http://${service.api.private_url}/api/query"
CUSTOM_DB = "host=${postgres.main.host} port=${postgres.main.port}"
PUBLIC_API = "https://${service.api.public_url}/v1"
}
dev {
env = {
PUBLIC_API = "http://${service.api.public_url}/v1"
}
}
}
All resource references (services, postgres, redis, storage, port, endpoint) can be used inside interpolated strings. Secret and config references cannot be interpolated; use them as standalone values.
Local development
Dev commands
The dev block overrides how a service runs during specific dev. Use it for hot-reload servers, watch modes, or anything optimized for a fast feedback loop. If the referenced build has no dev block, the build step is skipped in development.
service "web" {
build = build.nextjs
command = "npm start"
endpoint {
public = true
}
env = {
PORT = port
}
dev {
command = "npm run dev" # Handles building + serving with hot reload
}
}
The dev.env block is merged with the top-level env, so you only override the variables that differ.
Dev-only services
A service with a dev.command but no top-level command only runs in local development and is excluded from deployment. Useful for local versions of external services like mock servers:
service "mock-api" {
dev {
command = "npx json-server --watch db.json --port $PORT"
}
endpoint {}
env = {
PORT = port
}
}
Dev-only services cannot have a build reference, and can define endpoints that other services reference during development.
See Local development for ports, logs, isolated instances, and tunnels.
Monorepos
Use root on builds to set the working directory. Services inherit the build’s root, so commands like npm start run from the correct directory:
build "backend" {
base = "node"
root = "packages/backend"
command = "npm run build"
}
service "backend" {
build = build.backend
command = "node dist/index.js"
dev {
command = "npm run dev"
}
}
Dev-only services can set their own root.
Workers
Services without any endpoint act as background workers. They deploy like other services but don’t receive HTTP traffic, which is common for queue consumers and background processing.
service "worker" {
build = build.worker
command = "npm run worker"
env = {
DATABASE_URL = postgres.main.url
}
}
For work on a schedule, use Crons instead.
Pre-existing images
Services can run a pre-built container image instead of a build. image and build are mutually exclusive, and image-based services skip the build step entirely:
service "redis" {
image = "redis:7"
command = "redis-server --port $PORT"
endpoint {}
env = {
PORT = port
}
dev {
command = "redis-server --port $PORT"
}
}
image- container image reference (for example,redis:7orghcr.io/org/image:tag).- Use
dev.commandto run the equivalent process locally. - Deploy hooks work with image-based services, and you can mix build-based and image-based services in one project.
Deploy hooks
Run commands before or after deploying a service. Hooks run as one-off jobs with the same container and environment variables as the service. If a hook fails, the deployment is aborted.
service "api" {
build = build.api
command = "node dist/index.js"
endpoint {
public = true
}
env = {
PORT = port
DATABASE_URL = postgres.main.url
}
pre_deploy {
command = "npm run db:migrate"
}
post_deploy {
command = "npm run cache:warm"
}
}
postgres "main" {}
pre_deploy- runs before the service is deployed. Database migrations belong here: the schema is updated before new code runs, and a failed migration aborts the deployment instead of leaving a broken state.post_deploy- runs after the service is deployed (cache warming, notifications).
In development, run migrations with specific exec whenever the schema changes, for example specific exec api -- npm run db:push.
Graceful shutdown
When a service is redeployed or stopped, Specific sends your app a SIGTERM signal and waits a short grace period before forcibly stopping it. Handle SIGTERM to finish in-flight requests and close connections cleanly.
For the signal to reach your code, command should run your app as a process that receives signals. Running your server directly (node server.js, ./api) is the most reliable; wrappers and process managers can swallow the signal.
Serving static files
To serve static files, run a web server as a service. For example, with npx serve:
build "spa" {
base = "node"
command = "npm run build"
}
service "frontend" {
build = build.spa
command = "npx serve dist -l $PORT"
endpoint {
public = true
}
env = {
PORT = port
}
dev {
command = "npm run dev" # Use your framework's dev server
}
}
Production
Once deployed, stream logs, inspect metrics, and scale services from the dashboard. For querying logs and metrics from the CLI, see Observability.