Lab 1 — Start from nothing
An empty folder to a published site, with the browser open the whole time.
You have: nothing. You will have: a site you wrote, running locally, then published.
Needs: zel, and a container runtime from step 2 onward. Check with
zel doctor.
Create a content folder
zel new docs my-docs✓ my-docs site.yaml pages.yaml sections/home/hero.yaml sections/home/features.yaml menu.yaml 01-guide/_sidebar.yaml 01-guide/index.mdx assets/.gitkeep Next: zel dev my-docsWhat just happened. You have a small site that is complete and correct — not a template with
TODOin it. Every field written is a field the schema accepts, so each file is a working example of its own format.zel check my-docspasses right now.No
package.json, nonode_modules, nothing to install. This folder is only the words and the structure.Watch it while you write
zel dev my-docsEngine ghcr.io/mylife-inc/zellij-base:latest Content my-docs Serving http://localhost:3000 Edit anything under my-docs and reload. Ctrl-C to stop. ✓ /app/content site Acme · theme paper pages 1 guides 1 (1 page) · /guideIt validates first, installs what the site needs, then serves. The first run takes a couple of minutes — it pulls a 237 MB image and installs Next.js into a named volume. Later runs reuse both and start in seconds.
Leave this running. Open
my-docs/sections/home/hero.yamlin your editor:type: hero headline: Your product, explained subhead: Change this line and watch the browser. cta: - { label: Read the guide, href: /guide, style: primary }Save. The browser updates before you get back to it.
Now break something on purpose — change
type: herototype: heroo— and save. The terminal and the browser both say:my-docs/sections/home/hero.yaml unknown section type "heroo" did you mean "hero"?That is the loop. You are never guessing whether a change was valid.
Add a page
zel new page pricing --sections hero,pricing-table,faqThree section files appear, and
pages.yamlgains a route. The browser picks up/pricingwithout a restart.Not sure what a section type takes?
zel list sections # all nineteen zel explain pricing-table # one of them, in fullexplainprints every field, which are required, the allowed values, and the shape of anything nested. It is generated from the same schemas that validate your content, so it describes exactly what will be accepted.Add a guide
A landing page is sections. Documentation is Markdown.
zel new guide docs --title "Documentation"✓ 01-docs/_sidebar.yaml ✓ 01-docs/index.mdxAdd pages by creating files.
01-docs/install.mdx:--- title: Installing description: Get it running in two minutes. --- ## Requirements Anything that runs a container. <Callout type="tip" title="On a Mac"> `brew install colima && colima start` </Callout>Then list it in the sidebar:
title: Documentation order: 1 nav: - index - installIt appears at
/docs/install, in the sidebar, in the table of contents, and in ⌘K search.Validate before you ship
zel check my-docs✓ my-docs site Acme · theme paper pages 1 guides 1 (1 page) · /guide menu Guide home / routes 2Every internal link is resolved, every section type checked, every sidebar entry matched to a file that exists. It exits non-zero when something is wrong, so it works as a pre-commit hook.
This runs the engine in a container with your folder mounted read-only, so it needs a container runtime — and about a second.
Ship it
Two ways, and they produce genuinely different things. Pick one.
A · A static site on Netlify
Free, fast, and nothing runs. Right for almost every documentation site.
Get a Netlify account and its CLI. The CLI is the only npm thing in this lab, and only because Netlify's upload protocol is theirs:
npm install -g netlify-cli netlify loginA browser opens; authorise it.
Create a project.
netlify sites:create --name my-docsSite Created Admin URL: https://app.netlify.com/projects/my-docs URL: https://my-docs.netlify.app Project ID: 91774174-d4c2-41b5-aa4b-ac61eeadbf51Build and upload.
zel build my-docs --out dist netlify deploy --prod --dir dist✓ dist (19 entries) Deploy path: /Users/you/dist Website URL: https://my-docs.netlify.appGetting the two values CI needs
The site ID.
sites:createprinted it as Project ID; Netlify's API calls the same value the site ID. If you have lost it:netlify sites:list --json | jq -r '.[] | select(.name=="my-docs") | .site_id'The auth token.
netlify loginwrote one into netlify-cli's config, and you can read it back rather than making a second one:# macOS NETLIFY_CONFIG="$HOME/Library/Preferences/netlify/config.json" # Linux [ -f "$NETLIFY_CONFIG" ] || NETLIFY_CONFIG="$HOME/.config/netlify/config.json" export NETLIFY_SITE_ID=91774174-d4c2-41b5-aa4b-ac61eeadbf51 export NETLIFY_AUTH_TOKEN=$(jq -r '.users[.userId].auth.token' "$NETLIFY_CONFIG")Check it worked without printing the token:
echo "${#NETLIFY_AUTH_TOKEN} characters" # 40, starting nfc_A personal access token works too, and is the better choice for a shared pipeline: User settings → Applications → New access token. It can be revoked on its own without logging your laptop out, and it is not tied to your CLI session.
Put both into the repository: Settings → Secrets and variables → Actions → New repository secret, as
NETLIFY_AUTH_TOKENandNETLIFY_SITE_ID. That is what Lab 3 and Lab 4 expect.Once
zel deploylands, those two commands become one:zel deploy netlify my-docsA custom domain
Say you own
example.comand want the site atdocs.example.com.Find who actually serves your DNS
dig +short NS example.comThis is the step people skip, and it is the one that costs an afternoon. Where you bought the domain is often not where its DNS is served. A record added in the registrar's panel while the nameservers point elsewhere is inert — it publishes nothing, and
digkeeps returning nothing however long you wait.Whatever that command prints is where the next step happens.
Add one CNAME, there
Field Value Type CNAMEName / Host docsPoints to my-docs.netlify.appTTL whatever it offers Use the exact
*.netlify.apphostname fromsites:create. A CNAME to a hostname that does not exist fails silently.Tell Netlify the name is yours
Project → Domain management → Add a domain →
docs.example.com.Netlify verifies the CNAME it can already see, then issues a Let's Encrypt certificate. Until you do this, the request arrives at Netlify and gets a 404 — it has no way to know which project the hostname belongs to.
Confirm
dig +short docs.example.com && curl -sI https://docs.example.com | head -1my-docs.netlify.app. 13.52.188.95 HTTP/2 200
B · A container, running locally
Right when you need image optimisation, or when the site has to live wherever your other services live.
zel build my-docs --target image --tag my-docs:dev zel image run my-docs:dev✓ Built my-docs:dev — 148 MB ✓ Serving http://localhost:3000That is a Node runtime serving your site on port 3000 — the same artefact you would push to Railway, Fly.io, Cloud Run or ECS.
zel image push my-docs:dev --registry ghcr.io/youYou never wrote a Dockerfile. If your organisation requires one in the repository,
zel image dockerfileprints the onezelwould have used.
What you have
my-docs/
├── site.yaml
├── pages.yaml
├── menu.yaml
├── sections/home/
├── 01-docs/
└── assets/
A folder of text under version control, and a published site. No lockfile, no framework upgrade waiting for you, nothing that stops working because a dependency moved.
