CyberTrainDeploy

Deploy to production

A CyberTrain app ships as one native binary and a folder beside it. It speaks plain HTTP, so something in front of it terminates HTTPS: kamal-proxy when you deploy with Kamal, or Caddy on a server you run yourself. This page explains how the pieces fit and how the two ways compare; the guides linked at each step have every command.

You need
A Linux server you can SSH into, and a domain
You run
One binary, behind a reverse proxy, with SQLite beside it
Two ways
Kamal and Docker, or systemd and Caddy

You are here:Run time dist/

What runs in production

WhyKnowing what the binary does, and what it leaves to others, decides the rest of the setup.

cybertrain build compiles the framework, your controllers and models and the template interpreter into dist/blog, with every view embedded, and copies public/ beside it. Nothing else is installed where it runs: no Ruby, no gems.

productiontext
browser ──HTTPS──▶ reverse proxy (kamal-proxy or Caddy)
                     └─ HTTP/1.1 ──▶ dist/blog (port 3000)
                                       ├─ public/  static files, 404.html, 500.html
                                       └─ storage/production.sqlite3
  • It links the system’s SQLite and libc, so build it on the same OS and CPU as the machine that runs it. Building inside a Docker image, as the Kamal setup does, takes care of that.
  • It speaks plain HTTP/1.1 only. TLS and HTTP/2 are the reverse proxy’s job. In production the session cookie is Secure, so the site must be served over HTTPS or forms fail with Invalid authenticity token.
  • The database is one SQLite file. Run one process on one server, and keep the file on storage that outlives a deploy.
  • It is production by default and refuses to start without CYBERTRAIN_SECRET_KEY_BASE. ./blog migrate applies the migrations compiled into it.

Two ways to deploy

WhyBoth end with HTTPS in front of the same binary; they differ in where it is built and how a deploy swaps it.

Kamalsystemd and Caddy
BuiltIn a Docker image, on your machine or a remote builderOn the server, from a git checkout
In frontkamal-proxy, with a Let’s Encrypt certificateCaddy, with a Let’s Encrypt certificate
Static filesThe app serves public/Caddy serves dist/public/ itself
A deployThe new container takes traffic once its health check passes: nothing is refusedA restart: a few seconds of Caddy’s 500.html
Rolling backkamal rollback VERSIONPoint current at an earlier release and restart
The server needsSSH and Docker (kamal setup installs it), plus an image registryRuby, a C toolchain, the SQLite headers, the cybertrain CLI and Spinel

Either way, the app is one process with its SQLite file on one server.

Deploy with Kamal

Whycybertrain new already wrote the files; you fill in a server, a domain and a registry.

  • Dockerfilean Ubuntu build stage runs cybertrain setup and cybertrain build; a slim stage of the same release gets dist/ and the libraries it links
  • config/deploy.ymlthe server, the domain, kamal-proxy with TLS on port 3000, the health check and the database volume
  • .kamal/secretsreads KAMAL_REGISTRY_PASSWORD and CYBERTRAIN_SECRET_KEY_BASE from your environment, never the values
  • public/up.htmlthe page kamal-proxy polls before it sends traffic to a new container

Fill in the values marked CHANGE ME in config/deploy.yml, commit, then:

terminal — blog/shell
export KAMAL_REGISTRY_PASSWORD=...
export CYBERTRAIN_SECRET_KEY_BASE="$(openssl rand -hex 32)"  # once; keep it
kamal setup    # first time: Docker and kamal-proxy on the server, build, push, start
kamal deploy   # every deploy after that
  • Each deploy builds the image (Spinel stays in Docker’s layer cache after the first), pushes it, and starts a container that runs ./blog migrate and then exec ./blog on 0.0.0.0:3000.
  • kamal-proxy waits until /up.html answers 200, switches traffic to the new container, then stops the old one, which finishes its requests in flight on SIGTERM.
  • The database lives on the volume blog_storage, mounted at /app/storage, so it survives every deploy.
  • Set builder.arch to the server’s CPU. Spinel compiles inside the image, so building another architecture under emulation is slow; build on the server with builder.remote instead.

The step-by-step guide, with backups of the volume, is docs/deploy-kamal.md. examples/blog carries the same setup, and CI builds and smoke-tests its image.

Behind Caddy, under systemd

WhyNo Docker: the server builds the app from git, systemd keeps it running, Caddy faces the internet.

  • The app listens on 127.0.0.1:3000 only, so nothing reaches it except through Caddy. systemd starts it with dist/ as its working directory, since public/ and storage/ are resolved from there, and reads its secret from an environment file outside the repository.
  • A deploy script clones each release into its own directory, checks that gen/ is fresh, runs cybertrain build and dist/blog migrate, then points the current link at it, restarts the service and waits for up.html to answer; if it does not, the script switches back to the previous release. A failure before the switch leaves the running release untouched. Going back does not undo migrations, so they must stay compatible with the previous release; a failed first deploy has nothing to go back to and only exits with an error.
  • Caddy gets the certificate, redirects HTTP to HTTPS, serves the files in dist/public/ itself and proxies everything else to the app. While the app restarts, it answers with 500.html.
/etc/caddy/Caddyfilecaddyfile
blog.example.com {
	encode zstd gzip
	root * /srv/blog/current/dist/public

	@static file
	handle @static {
		file_server
	}

	handle {
		reverse_proxy 127.0.0.1:3000
	}

	handle_errors {
		rewrite * /500.html
		file_server
	}
}

The app adds X-Content-Type-Options and X-Frame-Options to what it serves; files Caddy serves from dist/public/ do not pass through the app, so add them in the site block if you want them there too. nginx works the same way: terminate TLS, serve dist/public/, proxy the rest.

The full walkthrough, from a fresh Ubuntu server to backups and rollbacks, is docs/deploy.en.md (the Japanese original is docs/deploy.md).

Configure it

WhyProduction settings come from the environment, so the same binary runs anywhere.

VariableIn production
CYBERTRAIN_SECRET_KEY_BASERequired. Signs the session cookie; changing it signs everyone out. Generate it once with openssl rand -hex 32
CYBERTRAIN_DATABASEThe SQLite file; storage/production.sqlite3 under the working directory by default. Keep it outside the release
CYBERTRAIN_HOST127.0.0.1 by default, right behind a proxy on the same machine; 0.0.0.0 in a container
PORT3000 by default; what the proxy forwards to
CYBERTRAIN_ENVproduction already in a built binary; development would start file watching and rebuilds

The tutorial’s Build for deployment step lists every variable and its config/app.rb attribute.

Run it day to day

  • Every change is a deploy. Views are embedded at build time, so even a view-only change needs a rebuild and a restart. Commit gen/ with the change: the deploy script stops on a stale gen/.
  • Migrations run before the new code serves: in the deploy script, or when the container starts. Remove a column in a later deploy, once no running code reads it.
  • Back up with SQLite’s .backup, not by copying the file: it takes a consistent copy while the app writes.
  • Rolling back past a migration means rolling the database back too, with db rollback from the newer release, before you switch.
  • Stopping is graceful: on SIGTERM the server stops accepting, finishes the requests in flight and exits, after 10 seconds at most.

Where next