Module 2 · Lesson 4

Ports, Storage & Health

Expose endpoints, persist data, and describe how your app stays healthy.

What you'll learn
  • How provides declares ports and protocols your app exposes
  • Using storage for persistent volumes
  • Marking operator-supplied content with content: operator
  • Referencing a volume's resolved path with $storage.<name>.path
  • Health checks — shorthand and expanded forms
  • The exposed flag for public-facing ports

The provides field

Your app listens on a port. But the world outside your container doesn't know which one, what protocol it speaks, or whether it should be reachable from the internet. The provides field makes all of that explicit.

Each entry in provides declares a single endpoint. The key properties are:

  • protocol — What the endpoint speaks: http, https, tcp, or grpc.
  • port — The port number your app listens on inside the container.
  • bind — Optional. The address to bind to (defaults to 0.0.0.0).
  • exposed — When true, this port is meant to be reachable from outside the deployment — your app's public entry point. When false (the default), it's only reachable by other components in the same deployment.

Build it up

1
Start with a single HTTP endpoint on port 3000, publicly accessible.
provides:
  - protocol: http
    port: 3000
    exposed: true
2
Add persistent storage. The data volume mounts at the given path and survives container restarts.
provides:
  - protocol: http
    port: 3000
    exposed: true

storage:
  data:
    path: /var/lib/app/data
    persistent: true
3
Add a health check using shorthand. An HTTP GET to this path is expected to return a 2xx response.
provides:
  - protocol: http
    port: 3000
    exposed: true

storage:
  data:
    path: /var/lib/app/data
    persistent: true

health: /health
4
Switch to the expanded health check form for full control over timing, retries, and startup grace period.
provides:
  - protocol: http
    port: 3000
    exposed: true

storage:
  data:
    path: /var/lib/app/data
    persistent: true

health:
  path: /api/health
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 60s

Who fills the volume?

Most volumes start empty and the app fills them — a database directory, an upload folder, a cache. But some volumes hold content only you can supply: a music server's library, a photo manager's originals, the files a file manager browses. A provider can't tell the two apart from path and persistent alone, so it would happily create the library volume empty and start the app — a deploy that looks successful and serves nothing. Mark those volumes with content: operator.

Unmarked — starts empty
storage:
  music:
    path: /music
    persistent: true
Marked — bound or refused
storage:
  music:
    path: /music
    content: operator
Where does the content come from?

Not from the Launchfile — the host path (~/Music, /srv/books) changes per machine, so it never enters the file. You supply it at launch: launchfile up --storage music=~/Music (repeat the flag for multiple volumes). The provider binds your directory at the declared path, or refuses the component with an error naming the volume and the flag that would satisfy it. It never creates the volume empty. persistent doesn't apply to a marked volume — your directory outlives the deployment by construction.

Referencing the path

Declaring a volume gives it a path — but you rarely want to hardcode that path into your app's configuration. The volume may live at a different real path depending on where you deploy. Reference the path it was actually provisioned at with the $storage.<name>.path expression instead of repeating the literal path.

Hardcoded — brittle
storage:
  data:
    path: /var/lib/app/data

env:
  DATA_DIR: /var/lib/app/data
Expression — portable
storage:
  data:
    path: /var/lib/app/data

env:
  DATA_DIR: $storage.data.path
Why not just repeat the path?

The two copies can drift. Change storage.data.path but forget the duplicate in env, and your app reads from the wrong directory — silently. $storage.<name>.path always resolves to wherever your volume was actually provisioned, so the same Launchfile runs correctly anywhere with no edits. Under the hood: where the volume actually lives differs by deployment — a container provider bind-mounts it at the declared path, while a native dev provider provisions a separate host directory — and the expression hides that difference. Catalog apps like Mailpit and AnythingLLM use it to wire their data paths into the environment.

Health check deep dive

The shorthand form covers most cases: give it a path and the rest is handled for you. When you need more control, expand it into an object.

Shorthand
health: /health
Expanded
health:
  path: /api/health
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 60s
Slow starters

The start_period field gives your app time to boot before failed health checks start counting against it. A Java app loading Spring context or a Rails app compiling assets might need 60–120 seconds. During the start period, failed health checks don't count toward the retry limit.

Not every app speaks HTTP. A background worker or a TCP-only service has nothing for a path check to hit — but it can still tell you it's alive. Use command instead of path: the provider runs the shell command, and a zero exit code means healthy.

HTTP check
health:
  path: /api/health
  retries: 3
Exec check
health:
  command: "pg_isready -q"
  retries: 3
If you declare both

path takes precedence over command — declare one or the other. And if your app has no meaningful health signal at all, you can omit health entirely: liveness falls back to a simpler signal, like whether the process is still running.

In the wild

Ghost is a professional publishing platform. Its Launchfile declares an HTTP endpoint, persistent storage for uploaded content, and a health check hitting the admin API.

ghost/LaunchfileView on GitHub
version: launch/v1
name: ghost
description: "Professional publishing platform"
repository: https://github.com/TryGhost/Ghost
logo: https://ghost.org/images/logos/ghost-logo-orb.png

image: ghost:5-alpine
provides:
  - protocol: http
    port: 2368
    exposed: true
requires:
  - type: mysql
    set_env:
      database__connection__host: $host
      database__connection__user: $user
      database__connection__password: $password
      database__connection__database: $name
      database__connection__port: $port
env:
  database__client:
    default: "mysql"
  url:
    default: $app.url
    required: true
    description: "Public URL of the Ghost instance"
storage:
  content:
    path: /var/lib/ghost/content
    persistent: true
health: /ghost/api/v4/admin/site/
restart: always
provides:
Declares an HTTP endpoint on port 2368, publicly exposed so readers can reach the blog.
storage:
The content volume at /var/lib/ghost/content holds themes, images, and uploads. Persistent means it survives redeploys.
health:
Hits the Ghost admin site endpoint. A 2xx means the app is ready to serve traffic.

Try it: npx launchfile up ghost to launch this app locally.View in catalog

Check your understanding

What does exposed: true mean on a provides entry?
Key takeaways
  • provides declares every network endpoint your app exposes, with protocol, port, and visibility.
  • storage declares named volumes. Set persistent: true for data that must survive restarts.
  • content: operator marks a volume you fill yourself — the provider binds your directory (--storage <volume>=<path>) or refuses the component, and never creates the volume empty.
  • $storage.<name>.path resolves to the real path the volume was actually provisioned at — reference it in env instead of hardcoding the path in two places.
  • health describes how your app's readiness is checked. Use the string shorthand for simple cases, the object form for tuning — with path for HTTP checks or command for everything else.
  • exposed: true means "the internet should reach this." Default is false (internal only).
esc
Type to search the docs