From laptop to live

One app, one folder, one command, and then it is on the internet.

This chapter is the whole trip, start to finish. We make a small app, put it in a repo, and give it to Yard. Then we change it, configure it, break it on purpose, and fix it. At the end, the app runs at https://hello.example.com, and you have written no Dockerfile, no pipeline file, and no server configuration.

Want to run software that someone else wrote, such as a password manager or a status page? That needs no repo and no code: Self-host a service is your chapter.

You can read this chapter before you have a host. It shows the shape of the work, and every later chapter fills in one part. When you want to follow along for real, Set up a host gets a VM ready, and you come back here.

1. An app on your laptop#

Start with a new Stario app:

uvx stario-create new hello
cd hello
just dev

Open http://127.0.0.1:8000, and the page says hello. That folder is all that Yard will need. Four of its files matter more than the rest, so remember them:

hello/
  pyproject.toml   lists stario as a dependency
  uv.lock          the exact version of every package
  app/main.py      the entry point: python -m app.main
  app/config.py    every setting, and where files go: $PROJECT_DATA_DIR, or ./data

Any app can go on Yard. We start with Stario because Yard knows it best. Other languages shows a Go, a Node, a Python, and an http-nu app at the end.

2. A repo#

Yard deploys from a repo on GitHub or Cursor Origin, so the app needs one:

git add -A
git commit -m "First version"
gh repo create you/hello --private --source . --push

On GitHub, add the new repo to your Yard app’s installation (Settings → Applications → Installed GitHub Apps → Configure). That is how Yard reads a private repo and hears about each push. A public repo works without it, but then no push wakes Yard, and the five-minute timer finds each push instead.

3. One command#

Still in the app’s folder, run:

yard app new --public hello.example.com

The name hello.example.com must point at the VM; the wildcard record from Set up a host already does. Yard reads the repo URL and the branch from your checkout, so there is nothing else to type. It creates the app, deploys it at once, and streams the deploy log while it works:

name  hello
repo  https://github.com/you/hello
ref  main
trigger  push
created  2026-10-27T15:00:00Z
runs  auto (not deployed yet)
Deploying hello now…
== sync https://github.com/you/hello main
== runs yard:python-uv (containerfile; stario: pyproject.toml declares stario)
== build localhost/hello:4f1c2a9b7d3e-88dff6cd
…
built 2026-10-27T15:01:12Z
== start localhost/hello:4f1c2a9b7d3e-88dff6cd
== started localhost/hello:4f1c2a9b7d3e-88dff6cd
ok  localhost/hello:4f1c2a9b7d3e-88dff6cd

Give Caddy a few seconds to get the certificate, and then open https://hello.example.com. The page that said hello on your laptop now says hello on the internet.

4. What Yard just did#

Read the == runs line again, because it is the interesting one. The repo has no Dockerfile, so Yard looked at the repo instead. It saw stario in pyproject.toml and knew what kind of app this is. Then it wrote a Containerfile from its yard:python-uv template, and ran the image with its own compose file:

  • The image installs the packages exactly as uv.lock lists them, with uv sync --locked.

  • The image starts the app the way you do: python -m app.main.

  • The container gets STARIO_HOST=0.0.0.0 and STARIO_PORT=8000, so that Caddy can reach the app.

  • The container gets STARIO_TRACER=json, so each request becomes one line in the log.

  • The container gets /data, a directory on the host that survives every deploy.

The image tag says what is inside: 4f1c2a9b7d3e is your commit, and 88dff6cd is the hash of the file Yard wrote. Nothing is hidden. To read the file, run:

yard app cat containerfile

Then yard status shows the app next to Yard itself:

name          state    cpu   mem   data  rev      bind                deploy
yard-app-1    up 6h  0.10%  60MB  128MB  9c2c7e1  yard.example.com    ok 43s · 2h
yard-caddy-1  up 6h  0.30%  24MB  128MB                               ok 43s · 2h
hello-app-1   up 1m  0.40%  48MB         4f1c2a9  hello.example.com   ok 38s · 1m

5. A change#

Open app/features/home/views.py and change the sentence on the page. Then push it:

git commit -am "Say something new"
git push

That is the whole deploy. GitHub tells Yard about the push, and Yard builds the new commit and swaps the container. Caddy keeps sending visitors to the same container name, so nobody sees an error during the swap. On GitHub, the commit gets a green check that links to the live site. Run yard app logs --deploy -f if you want to watch it happen.

6. A setting#

The app reads its name from the APP_NAME environment variable (see app/config.py). Set it on the host:

yard env set APP_NAME="Hello from Yard"
Saved. hello keeps its old settings until the next deploy.
Apply now: yard app deploy --app hello

Saving never restarts the app, so you choose when the change lands. Run the line it printed:

yard app deploy
Deploying hello now…
== sync https://github.com/you/hello main
== runs yard:python-uv (containerfile; stario: pyproject.toml declares stario)
== reuse localhost/hello:4f1c2a9b7d3e-88dff6cd
== start localhost/hello:4f1c2a9b7d3e-88dff6cd
== started localhost/hello:4f1c2a9b7d3e-88dff6cd
ok  localhost/hello:4f1c2a9b7d3e-88dff6cd

Look for == reuse: the code did not change, so there is no build. Compose starts a new container from the same image with the new value, and the page title changes.

One setting you never have to make is where the data goes. A container is thrown away at each deploy, so a file inside it is lost. But app/config.py keeps every file in PROJECT_DATA_DIR, and Yard sets that to /data, a directory on the host. When the app gets a database (the stario-litewriter skill in the app adds SQLite through litewriter), its file is app.sqlite3 in /data. On the host, that is /srv/yard/apps/hello/data/app.sqlite3, and every deploy keeps it. On your laptop the same code writes to ./data, which git ignores.

Secrets go the same way as APP_NAME, and never into the repo. When the code reads a setting with no default, such as os.environ["SESSION_SECRET"], the next deploy ends with a hint that names it and the line that sets it.

7. A mistake#

Now break something on purpose. Add a package, but commit only half of the change:

uv add httpx
git commit -m "Add httpx" pyproject.toml
git push

The new pyproject.toml asks for httpx, but the old uv.lock does not list it. The build stops:

error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
…
failed  localhost/hello:7b0e3d5c1a29-88dff6cd
Read the full log: yard app logs --deploy --app hello

The live site does not notice. The build failed before anything started, so the old container keeps running, and on GitHub the commit gets a red cross. Commit the lock, and push again:

git commit -m "Lock httpx" uv.lock
git push

That is how Yard handles every mistake. A build that fails never starts. A new container that starts but never gets healthy is stopped, and the previous image comes back. When Yard cannot tell what to build, it stops before the build and says what it found. When Yard refuses lists each message and its fix.

8. Your own Containerfile#

The written file is a good start. When you want more, take it into the repo and change it. The first change worth making is a healthcheck. With one, Yard waits until the new container answers before it lets the old one go, and it brings the old image back when the new one never answers.

yard app cat containerfile > Containerfile

Add one line at the end of the file:

HEALTHCHECK --interval=10s --timeout=3s --start-period=30s \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/')"

Then tell Yard to build this file, and push it:

git add Containerfile
git commit -m "Own the Containerfile"
yard app set --runs Containerfile
git push

Set --runs before you push. Otherwise the push deploys on auto, and auto refuses to switch from Yard’s file to yours by itself. What an app runs explains that guard.

What you did not do#

Look back over the steps. You did not write a Dockerfile, a CI pipeline, or a Caddy or nginx configuration. You did not buy a certificate, set up a registry, open a firewall port, or log in to the server. Yard did each of those jobs, and each one left a record that you can read:

  • yard app cat containerfile prints the file that built the image.

  • yard app logs --deploy prints the deploy, step by step.

  • yard events --app hello lists every push, check, deploy, and setting change.

Other languages#

Yard reads more than Stario. The steps are the same four: commit, push, yard app new, and open the address. Only the files differ, and so does the == runs line that says what Yard found.

A Go service needs a go.mod and one package main, at the root or in cmd/:

api/
  go.mod     go 1.26
  main.go    package main
 
== runs yard:go (containerfile; go: go.mod)

Yard builds one static binary with go build and runs it on a distroless image.

A Node app needs a package.json with a start script:

shop/
  package.json        "build": "vite build", "start": "node server.js"
  package-lock.json
 
== runs yard:node-npm (containerfile; node: package.json start script)

Yard runs npm ci, then npm run build because there is a build script, and starts with npm start.

A plain Python app needs uv.lock or requirements.txt, and one entry point such as app.py:

notes/
  requirements.txt   flask
  app.py
 
== runs yard:python-pip (containerfile; python: requirements.txt)

Yard installs requirements.txt into a virtual environment and runs python app.py.

A cross.stream app needs only serve.nu:

board/
  serve.nu   {|req| ... }
 
== runs yard:http-nu (containerfile; http-nu: serve.nu)

Yard downloads one http-nu binary, checks its sha256, and keeps the xs store in /data.

Only Stario and http-nu listen on the right address without help. The Go, Node, and plain Python apps must listen on 0.0.0.0, on the port in the PORT variable, which Yard sets to 8000. Flask’s app.run() listens on 127.0.0.1:5000 by default, so it needs host="0.0.0.0" and the port.

A Rust app — or anything else — brings its own Dockerfile:

api/
  Dockerfile   FROM rust:1-slim …
  Cargo.toml
  src/main.rs
 
== runs Dockerfile (containerfile; found Dockerfile)

Yard builds it as it is.

An app does not need code of its own. A public image or a compose file at a URL runs the same way, and Yard follows it as it follows a branch:

yard app new --runs docker.io/library/postgres:17 -e POSTGRES_PASSWORD=…
yard app new --runs https://raw.githubusercontent.com/Bobowski/yard/main/examples/uptime-kuma.compose.yml --public status.example.com

Where to go next#

  • The whole idea explains the parts that you just used, and what Yard leaves out.

  • Set up a host gets a VM ready, if you only read along.

  • What an app runs shows every file that Yard reads, and every message it can stop with.

  • Day to day covers logs, settings, domains, rollbacks, and cleanup.