Services

Services are the deployable units of a project: what they are, how they are configured, and how they scale.

A service is one running program. This can be a web app, an API, a background worker, a queue consumer, or a self-hosted piece of software you didn't write. It lives in exactly one environment and consists of its own image, its own variables, and its own compute allocation.

Services are the core of Lucity: everything else in a project exists to be used by services. Databases, buckets, key-value stores and volumes are all things you connect to a service.

Each service has a name consisting of 2 to 16 characters, lowercase letters, digits and hyphens. That name shows up in the private hostname, the generated domain, and the logs, so pick something you'll still recognize in six months. You cannot change it later.

Source builds and prebuilt images

Every service is either built from a repository or pulled from a registry. You choose which one when you create the service, and that choice determines most of what follows.

From a repositoryFrom an image
Where the image comes fromLucity builds it from sourcePulled from a public registry
Tracked branch, auto-deploy, CI deploysYesNo
Rebuild and redeployYesNo
Default port8080Lowest TCP EXPOSE port in the image
Default start commandDetected by the builderThe image's entrypoint and command
Run-as userImage defaultConfigurable
Vulnerability scanningYesNo

Building from a repository is the common case, and the one the quickstart walks through. Lucity reads the repository, works out what it is, builds a container image, and deploys it. When you push to the tracked branch, all of that happens again.

Deploying an image is for software you are not building yourself, such as Ghost, MySQL, Grafana, or an internal image someone else publishes. You can pick one from the Docker Hub search in the dashboard, or type any public image reference such as ghcr.io/owner/thing:1.4.

Be aware that an image reference without a tag is treated as latest, which is commonly used as a moving tag. That means the service might pull a newer image when an instance starts, if the maintainer has published one in the meantime. Lucity only pulls an image when the machine running the instance does not already have it, so this happens when an instance lands somewhere new rather than on every restart. Pin an exact version when you want to know what is running.

The image reference is fixed when the service is created. There is no way to change the tag afterwards, so moving to a new version means adding a service on the new image and removing the old one. Images from private registries are not supported yet.

Ports

A service can expose a port. If it does, the service is reachable inside its environment by default, and it can optionally also be assigned a domain. Some services, such as internal APIs, don't need a public domain at all, since their clients reach them from within the environment.

A service with no port configured can be the right choice for something like a worker that only reads from a queue or an external API.

Services built from a repository default to port 8080, and Lucity sets two variables in the container to match:

VariableValue
PORTThe service port
HOST0.0.0.0

Most frameworks read PORT without being asked, which is why a stock application usually works untouched. Binding to localhost instead of 0.0.0.0 is the classic way to end up with a container that looks healthy while nothing can actually reach it.

If you define PORT or HOST yourself as a variable, your value wins and Lucity stops injecting its own.

Services created from an image take the lowest TCP port that the image declares with EXPOSE. Ghost lands on 2368, MySQL on 3306, and an image that exposes nothing at all gets no port.

You can change the port under Settings → Networking → Port. Clearing the field turns the service into a service without a port, which removes its cluster address, its domains, and its health checks.

Start command

Services built from a repository get a start command from the builder, derived from the same signals it used to detect the framework: an npm start script, a Python entrypoint, or the binary it just compiled. Services created from an image use that image's entrypoint and command.

You can override it under Settings → Deploy → Custom Start Command. The placeholder shows the detected default, so you can always see what you are replacing.

The command runs through /bin/sh -c, so shell syntax works: variable expansion, &&, and semicolons all behave as you would expect.

Graceful shutdown

When a deployment replaces an instance, the old one is asked to stop rather than killed outright. Your process receives SIGTERM and has about twenty seconds to finish what it is doing and exit on its own. Ignore the signal and it is killed when the time runs out, dropping whatever was still in flight.

Most frameworks handle this for you. A custom start command is what usually gets in the way, since the command runs through a shell and the shell is the process that receives the signal. Prefix the real process with exec so that it replaces the shell:

npx prisma migrate deploy && exec node server.js

Without exec, the shell takes the signal and your application only finds out when it is killed. With it, the application gets the full grace period.

Health checks

By default, Lucity opens a TCP connection to the service port every 5 seconds with a 3 second timeout. An instance is considered ready after one success, and taken out of rotation after three consecutive failures. This needs no configuration and it is the right answer for most services: if the socket accepts a connection, the application is listening.

It is the wrong answer when your application accepts connections before it can actually serve them. An application that binds its port and then spends 40 seconds warming a cache will receive traffic it cannot answer. That is what the HTTP check is for, and you can turn it on under Settings → Networking → Health Check:

FieldDefaultWhat it does
PathRequiredThe path to request, for example /health/ready
PortService portUseful when health lives on a separate admin port
Delay0sHow long to wait before the first check
Period5sHow often to check
Timeout3sHow long to wait for a response
Failures3Consecutive failures before the instance leaves rotation
Startup budget0Extra failures tolerated while the instance is starting

Any response from 200 to 399 counts as healthy.

The startup budget is the setting worth understanding. The steady-state values have to stay tight, because that is how a wedged instance gets pulled out of rotation quickly, but a slow boot needs the opposite. The startup budget separates the two concerns: a period of 5 seconds with a startup budget of 24 gives a new instance two minutes to come up, and as soon as it passes once, the strict steady-state rules take over.

Lucity uses readiness checks rather than liveness checks. A failing check takes an instance out of rotation, it does not kill the container.

If a new instance never passes its check, the rollout is reported as failed and the old instance keeps serving. See deployments for what that looks like and what to do about it.

CPU and memory

Giving a service more CPU and memory is what is commonly called vertical scaling: the instances you already run each get more room to work in.

Under Settings → Compute you pick a CPU and a memory size, and both apply per instance:

  • CPU: 0.25, 0.5, 1, 2 or 4 vCPU, defaulting to 0.5.
  • Memory: 256 MB up to 16 GB, defaulting to 512 MB.

Both values are limits, and the two behave differently once you hit the ceiling. Exceeding the CPU limit means your process is throttled and simply gets slower. Exceeding the memory limit means the kernel kills it, and the instance restarts with OOM killed recorded on the deployment. If a service dies at exactly the same point in its workload every time, that is the first thing to check.

Saving new values rolls the service out without a rebuild. See configuration changes.

Each environment has a total budget of 32 vCPU and 64 GB across everything inside it. That is plenty of room for a normal project, and a backstop against a runaway autoscaler.

Replicas and autoscaling

Running several copies of a service is what is commonly called horizontal scaling. Each replica is its own container and gets the full CPU and memory limits you picked, so four replicas at 1 vCPU each have a vCPU to themselves.

A service runs between 1 and 20 instances. You can set the number yourself under Settings → Scaling → Replicas, or hand the decision to the autoscaler.

Adding or removing replicas does not touch the instances that are already running. Existing instances keep serving, new ones join them, and traffic spreads across whatever is ready.

Autoscaling watches CPU usage and adjusts the count for you. You set a minimum, a maximum, and a CPU percentage to aim for, and the autoscaler stays inside that range.

Scaling up reacts within about 30 seconds. Scaling down waits for 5 minutes of sustained calm and then removes at most one instance per minute, so that a short dip in traffic does not take away capacity you are about to need again.

Running more than one instance assumes that the service is stateless: no session state held in process memory, no uploads written to the container filesystem, and no assumption that a scheduled job runs on one particular machine. Move that state into PostgreSQL, a key-value store or a bucket before you scale out. Otherwise the requests that reach the instance holding the state work, and the rest do not.

This is also why a service with a mounted volume is pinned to a single replica. A volume is one disk and can only be attached to one machine at a time, so instances cannot share it. See one volume, one service.

Networking

Every service with a port gets a private DNS name inside its environment, reachable on the same port the container listens on. From another service in the same environment the short name lucity-app-<service> is enough, so a variable pointing one service at another can be as short as http://lucity-app-api:8080.

The full name is shown, ready to copy, under Settings → Networking → Private Networking, and it has this shape:

lucity-app-<service>.<environment-namespace>.svc.cluster.local

This address is not reachable from the internet, and nothing outside the environment can reach it either. Your production database is not reachable from your staging application, in the same way that it is not reachable from someone else's workspace.

All services can reach the public internet, call third-party APIs, and pull from package registries.

To expose a service to the world, give it a domain. That is one click for a platform domain with a certificate included, or a few DNS records for a hostname you own. See domains.

Volume mounts

Lucity service containers are temporary. When you deploy a new version of your app, the old containers are deleted once the new one is ready. This means that files written to the filesystem are gone after a rollout.

Most of the time that is fine, and the answer is a bucket for uploads and a database for data. Sometimes it is not, and you need a disk that outlives the container: software that insists on a data directory, a self-hosted tool with a content folder, or a database you are running yourself.

To get one, create a volume in the environment and mount it on a service at a path. Attaching a volume constrains the service in two ways: it runs as a single instance and cannot autoscale, because a disk belongs to one instance, and every deploy stops it before starting the replacement instead of handing over cleanly, so there is a short gap each time.

Under the hood

Under the hood, each Lucity service is a Kubernetes Deployment, a Service, an optional HTTPRoute, and a Secret holding its variables, all rendered from Helm values that Lucity computes for you. Containers run with no privilege escalation, all capabilities dropped, a seccomp profile applied, and no service account token mounted.

There is no proprietary runtime object anywhere in that list, which is what makes ejecting work. You get the chart and the values, and the same service keeps running on your own infrastructure.

Next steps

  • Variables to configure a service and connect it to a database or bucket
  • Deployments for what happens on each deploy
  • Domains to put a service on a hostname you own
  • Metrics to see what a service actually uses before you resize it