Deploy a Facility#
This page builds one facility deployment from an empty directory to running
containers, using only osprey commands. Follow it in order and you end up
with a git repository you can commit, review, and hand to a colleague.
The facility is called Demo Facility. It runs three services, serves two web terminals — one read-only, one write-capable — and ships one container the facility writes itself.
What You’ll Learn
Creating a deployment repository with
osprey initEditing the profile down to the services your facility actually runs
Adding a container the facility owns, with its own image-build job
Emitting the CI pipeline and the health check with
osprey scaffold ciRendering the build and bringing the stack up
Prerequisites: A working OSPREY installation, git, and Docker or
Podman running locally.
Time: 30–45 minutes.
Tip
Read Build Profiles first if the words preset, profile and
build are new. This page assumes you know that the profile is the source
of truth and build/ is a rendered artifact.
What you are building#
One repository, holding the facility’s editable source and the deployment scaffolding that goes with it:
demo-facility/
├── profile.yml the manifest you own — edit this
├── data/ channel databases, knowledge, lattice
├── personas/ one delta per web-terminal persona
├── services/facility-mcp/ the facility's own container
├── scripts/verify.sh the post-deploy health check
├── .env the deployment's one secret store
├── build/ render target, kept out of git
├── var/ agent memory and audit log, kept out of git
├── .gitlab-ci.yml emitted from the profile's deploy: block
├── ci-extra.yml your own CI jobs; never regenerated
└── .gitignore
Three services run:
Service |
Comes from |
What it does |
|---|---|---|
|
packaged |
Telemetry store for the agent’s logs and metrics. |
|
packaged |
A PyAT soft-IOC serving EPICS Channel Access on port 5064, standing in for the real machine. |
|
this profile |
The facility’s own MCP server on port 8200, built from a Dockerfile that lives in the profile. |
The first two are packaged with OSPREY and come from their own upstreams. The third is the interesting one: it is the part no framework can ship for you, and everything about how a facility-owned container is declared, built, and reached is in Step 4 — Add the facility’s own container.
Step 1 — Create the facility repository#
osprey init demo-facility --preset control-assistant
cd demo-facility
The command writes the whole repository, runs git init at its root, and
commits nothing. It prints the directory layout, the two persona deltas it
rendered, and where the secrets go.
It also tells you there is no CI pipeline yet. That is expected: the profile
ships with its deploy: block commented out, so there are no coordinates to
render a pipeline from. Step 5 fills the block in and Step 7 renders the
pipeline from it.
Step 2 — Trim the profile to this facility’s stack#
Open profile.yml. The control-assistant preset ships a fuller
stack than Demo Facility runs, so the first edit is subtraction. Delete:
the top-level
bluesky:,bluesky_panels:anddispatch:blocks. Each of these is a trigger: leaving one in place adds its service to the deployment, whatever else you write below.from
skills:—writing-bluesky-plansandoperating-bluesky-scans.from
agents:—logbook-searchandlogbook-deep-research. Both query a logbook database at runtime, and this facility does not deploy one.from
web_panels:—ariel,eventsandbluesky.from
config:— theclaude_code.servers.bluesky.enabledline and everyweb.panels.events.*andweb.panels.bluesky.*override. The panels they configure no longer exist.
Keep the virtual_accelerator: block. It is the trigger for the soft-IOC this
facility drives.
Step 3 — Name the facility and pin its services#
Still in profile.yml, under config:, set these six values. Some
are already present with a different value; some ship commented out.
config:
control_system.type: virtual_accelerator
claude_code.servers.health.enabled: true
system.timezone: America/Los_Angeles
facility.name: Demo Facility
facility.prefix: demo
deployed_services:
- openobserve
control_system.type: virtual_accelerator points the agent at the deployed
soft-IOC, so correctors move and BPMs read through exactly the approval and
limit layers a live machine would use. The preset ships mock, which touches
nothing.
deployed_services looks too short, and is not. The virtual_accelerator:
block and the services: entry you add in Step 4 each append their own
service to this list at build time. Naming openobserve explicitly is what
keeps the packaged skeleton’s postgresql out — declared is not deployed,
and this list is what osprey up reads.
facility.prefix becomes the container-name prefix for the web tier
(demo-nginx, demo-web-alice), so keep it short and distinct from the
project name.
Then rename the two personas, further down the same config: block under
modules.web_terminals::
personas:
readonly:
project: demo-facility-readonly
project_path: build/demo-facility-readonly
build_profile: personas/readonly.yml
readwrite:
project: demo-facility-readwrite
project_path: build/demo-facility-readwrite
build_profile: personas/readwrite.yml
Important
Each persona’s project must equal the basename of its project_path.
osprey build derives a persona render’s name the same way, which is how
it lands exactly where the web tier mounts it.
Step 4 — Add the facility’s own container#
Declare the service, and the MCP server it serves. Replace the services: {}
line and the commented mcp_servers: example with:
services:
facility-mcp:
template: services/facility-mcp
config:
port: 8200
mcp_servers:
facility:
port: 8200
transport: http
permissions:
allow: [machine_status]
template: is profile-relative, so the next thing to do is write that
directory. The port appears twice because it is the same fact told to two
parties: the container publishes it, and the agent dials it.
Now create services/facility-mcp/ with four files.
requirements.txt:
mcp==1.9.4
server.py — a read-only tool over streamable HTTP. Nothing here imports
OSPREY: the agent reaches this container by URL, so the framework never has to
be installed alongside it.
"""Demo Facility's own MCP server: read-only machine-status lookups."""
from __future__ import annotations
import json
import os
from pathlib import Path
from mcp.server.fastmcp import FastMCP
STATUS_FILE = Path(os.environ.get("FACILITY_STATUS_FILE", "/data/machine-status.json"))
mcp = FastMCP("demo-facility", host="0.0.0.0", port=int(os.environ.get("PORT", "8200")))
@mcp.tool()
def machine_status() -> str:
"""Report the control room's current machine state and operating mode."""
if not STATUS_FILE.is_file():
return f"No machine status available ({STATUS_FILE} is not present)."
return json.dumps(json.loads(STATUS_FILE.read_text()), indent=2)
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Note
Keep a facility tool like this read-only. Anything that writes to the machine belongs behind the control-system connector, which is the single interface every write goes through.
Dockerfile — a service directory that carries one earns its own image-build
job in the pipeline:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py ./
# Unbuffered so container logs appear as the server writes them rather than
# when the process exits.
ENV PYTHONUNBUFFERED=1
EXPOSE 8200
CMD ["python", "server.py"]
docker-compose.yml.j2 — the same channel every packaged service uses: one
compose template per service directory, rendered by the build.
services:
facility-mcp:
image: ${OSPREY_FACILITY_MCP_IMAGE:-{{ (services['facility-mcp'] | default({})).image | default(osprey_labels.project_name ~ '-facility-mcp:local') }}}
build:
# With multiple `-f` compose files every relative path resolves against
# the FIRST file's directory — the compose project dir, not this file's
# own subdir — so the context is the service directory by name.
context: ./facility-mcp
dockerfile: Dockerfile
# container_name is a HOST-GLOBAL identifier: namespace it per-project so
# two OSPREY projects can run this service on one host.
container_name: {{ osprey_labels.project_name }}-facility-mcp
labels:
osprey.project.name: "{{ osprey_labels.project_name }}"
osprey.project.root: "{{ osprey_labels.project_root }}"
osprey.deployed.at: "{{ osprey_labels.deployed_at }}"
restart: unless-stopped
ports:
- "{{ deployment.bind_address | default('127.0.0.1') }}:{{ (services['facility-mcp'] | default({})).port | default(8200) }}:8200/tcp"
environment:
PORT: "8200"
FACILITY_STATUS_FILE: /data/machine-status.json
TZ: {{ system.timezone }}
volumes:
- facility_mcp_data:/data
networks:
- osprey-network
healthcheck:
test: ["CMD-SHELL", "python -c \"import socket,sys; s=socket.socket(); s.settimeout(3); sys.exit(0 if s.connect_ex(('localhost', 8200)) == 0 else 1)\""]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
facility_mcp_data:
networks:
osprey-network:
The image reference follows the framework convention — environment variable,
then config key, then a local tag. A laptop deploy builds the image from the
sources beside the template; the deploy host selects the pipeline’s pushed image
by setting OSPREY_FACILITY_MCP_IMAGE. Container Deployment covers the
template variables in full.
Step 5 — Fill in the deployment coordinates#
Two edits, and they belong together.
First, env: and the deploy: block. Replace the preset’s env.required
list, and uncomment and fill in the deploy: block near the bottom of the
file:
env:
required:
- DEMO_REGISTRY_TOKEN
deploy:
ci: gitlab
registry:
url: registry.example.org/accelerator/demo-facility
token_env_var: DEMO_REGISTRY_TOKEN
host:
name: demo-deploy
fqdn: demo-deploy.example.org
user: osprey
project_path: /opt/demo-facility
image_source: local
Substitute your own registry and host. registry.url carries no scheme — it
appears verbatim inside an image name. host.name must be ssh-resolvable for
whoever presses the deploy button, and host.project_path is where this
repository is checked out on that server. Credentials are named here, never
written here.
image_source: local says the deploy host builds the web-terminal images
itself from the rendered persona projects, rather than pulling them from a
registry.
Important
In the same edit, delete the image_source: local line from the
modules.web_terminals: block under config:. The deploy: block is
that fact’s only home, and the build writes the value into the rendered
config for you. Saying it in both places is refused by name — the two are
free to disagree, and the disagreement decides whether the host builds images
or pulls them.
Step 6 — Add the secrets#
The profile owns this deployment’s secrets, so that is where they go.
Step 1 may already have written .env for you, seeded from a matching
key your shell exports — it says so in its output. If that file does not exist
yet, start from the documented list:
cp .env.example .env
Either way, edit .env and set ANTHROPIC_API_KEY (or the key for
whichever provider your profile names) and DEMO_REGISTRY_TOKEN. The
repository’s .gitignore keeps .env out of git.
Service credentials such as ZO_ROOT_USER_PASSWORD stay blank. osprey up
mints a strong value for each unset one on first deploy and writes it back into
.env, so a later rebuild comes up on the same credentials the running
volumes were initialized with.
.env.example is written by osprey init. After you change
env.required, update the example alongside it — it is the documented list
that whoever sets this up next will read.
Step 7 — Validate, then emit the deployment files#
osprey validate
A valid profile reports its name and its deploy target — Deploy: gitlab CI →
osprey@demo-deploy. If anything is wrong, every problem is reported at once
rather than one per run.
Now render the deployment files from the deploy: block:
osprey scaffold ci
Two files appear:
.gitlab-ci.ymlThe pipeline, at the repository root. Its header lists the CI/CD variables the pipeline reads, which you set in your GitLab project settings, masked and protected:
DEMO_REGISTRY_TOKEN(the registry credential named by yourdeploy:block) andOSPREY_DEPLOY_SSH_KEY, a File-type variable holding the private key for the deploy-host account. The SSH key is CI-only — it authenticates the deploy job and is never part of the deployment’s own environment, which is why it is not inenv.required.scripts/verify.shThe post-deploy health check, at the repository root.
Re-run osprey scaffold ci whenever the deploy: block changes. It is
safe to re-run: a file whose content already matches is left untouched, and a
file the scaffolder did not write is reported and left alone unless you pass
--force.
ci-extra.yml, which osprey init created in Step 1, is the
facility’s own include point. The pipeline includes it after everything the
scaffolder emits, so a job you add there can also override a scaffolded job by
redefining it under the same name. Nothing ever regenerates that file.
Step 8 — Build the project#
osprey build
osprey build walks up to the repository’s profile.yml and renders
build/ from it, from whichever directory inside the repository you run it.
Watch for these lines in the output:
Injected 1 profile service(s) for deploy—facility-mcpwas picked up.Injected Virtual Accelerator soft-IOC (CA port 5064).Persisted 1 MCP server(s) to config.yml— the agent can reachfacility-mcp.
Confirm the service list is exactly the three you expect:
osprey config --rendered | grep -A3 '^deployed_services:'
It should read openobserve, facility-mcp, virtual_accelerator.
Step 9 — Deploy and check#
osprey up -d
Note
Running OSPREY from a source checkout rather than a released install?
Add --dev — osprey up otherwise refuses, because a container built
from PyPI would run different code than your checkout, and --dev builds
the image from the checkout instead; see Container Deployment for that
workflow.
The first run is slow: the virtual accelerator and the facility’s own image are
both built locally. When the containers are up, osprey up runs
scripts/verify.sh itself and prints a summary of the published endpoints.
osprey status
Read the lines above the status table first. A drift report there means
build/ was rendered from an older profile than the one on disk now, and the
fix is osprey build, not a restart.
You can run the health check by hand at any time, and it is worth doing once before you trust it:
./scripts/verify.sh # every probe
./scripts/verify.sh services # one group
It always exits 0 — the output is the report, and the exit code says nothing. Probes are advisory: a failed probe tells you where to look, and must never be the reason a deploy is called a failure.
What the pipeline does#
The walkthrough above is the local path. The pipeline in .gitlab-ci.yml
takes the same profile through three stages:
validate runs on every commit and needs no credentials. It runs
osprey validate, then osprey build with --skip-lifecycle
--skip-deps — CI has no container runtime for post-build hooks, and nothing
there runs the agent, so its virtual environment would be dead weight. The
render is published as an artifact so a reviewer can see exactly what the
commit produces.
images builds one image per facility-owned service that carries a
Dockerfile — here, just facility-mcp. The build context is the service
directory in the source zone, not the rendered copy. Every build pushes a
commit-SHA tag; only the default branch moves :latest, so a feature branch
can never reach the deploy host by accident.
deploy is manual and default-branch only, serialized by a resource group so two operators cannot interleave on the host. It re-renders on the host from the same commit rather than unpacking the artifact, which is what makes the running stack reproducible from git alone:
osprey build
osprey users env-production --output .env.production
osprey up -d
osprey users env-production writes the env file every per-user web-terminal
container runs with, from the deploy host’s own .env. Passing
--output is not optional: without it the command writes the assembled
secrets to stdout, which in a pipeline is the job log. --output also creates
the file at mode 0600 from its first byte, which a shell redirect would not.
Changing something later#
The loop is always the same: edit the profile, rebuild, redeploy. Run it anywhere inside the repository:
osprey set connector=epics # or edit profile.yml by hand
osprey build
osprey up -d
Or in one step, osprey up --build -d. Every build re-renders everything the
framework owns and preserves what you own: .env, var/, and the
repository’s git history. The source zone itself is never touched by a build.
If you changed the deploy: block, run osprey scaffold ci again first
so the pipeline and health check match the new coordinates.
Operating it#
Everything past “the stack is up” — triaging a service that is down, deciding when to re-scaffold, and reconciling a secret a container volume adopted at first start — is judgment rather than a command sequence. Install the runbook and let the OSPREY agent work from it. From the repository root:
osprey skills install osprey-deploy-ops --target .claude/skills
See also
- Build Profiles
What lives in a profile, the convention directories, and taking ownership of a framework artifact.
- Container Deployment
The container-deployment reference: service configuration, compose template variables, image overrides, and the
--devworkflow.- Multi-User Support
The web tier this facility deploys — the landing page and one containerized terminal per operator.
- Use the Virtual Accelerator
The PyAT soft-IOC, and driving it from the agent.
- CLI Reference
Every
ospreycommand and flag.