Deploying to a server
Page 13 of 14 in Learn Jadpo
By the end you can build the app into one program and run it against a real database.
On this page
jadpo dev runs the app with a throwaway database and catches every
email. To deploy it, you build it into one program and run it with a
real database and real settings. On this page you do that on your own
machine, the same way you would on a server. Page 14 deploys it to
Cloudflare Workers instead.
Name the real database
Until now, Jadpo started every database itself. A deployed app has to
be told where its database is. Add a connection for it to app.jadpo,
the same way page 11 added the mail server:
app.jadpo, changed. The main connection is new.
use todos/entities { User }application { jadpo: "0.1" display_name: "Todos" connections: { main: { driver: Driver.postgres url: config.database_url } mail: { driver: Driver.smtp host: config.smtp_host credential: { username: config.smtp_username password: config.smtp_password } } } email: { connection: mail from: config.mail_from }}principal { user: { subject: User.email }}
The connection must be called main, the name Jadpo gives the
database every entity is stored in unless it says otherwise. That's a
different thing from the API called main on page 2: one is a
connection, the other an API, and Jadpo always knows which from where
the name is written.
Add the setting it reads to config.jadpo:
config.jadpo, changed. The database_url setting is new.
config { /// The PostgreSQL database the app keeps its data in. database_url: PostgresUrl /// Who the app's emails come from. mail_from: Email { default: "todos@example.com" } /// How far ahead a todo may be due. max_due_ahead: Duration { default: 365d test: 30d } /// The address of the site people sign in on. site: Url { default: "http://localhost:3000" } /// The mail server the app hands its emails to. smtp_host: Hostname { default: "localhost" } /// The password for the mail server. smtp_password: Text { secret: true } /// The user name for the mail server. smtp_username: Text { default: "todos" }}
PostgresUrl is a standard type for a database address, such as
postgres:/. It holds a password, so it
is always a secret, and it has no default, so a deployed app won't
start without it. jadpo dev and jadpo test still start their own
database when DATABASE_URL isn't set, so nothing changes on your
machine. jadpo check and jadpo test pass as before.
Build the program
jadpo buildbinary: build/target/app/app build: b_eae4cf8f9e2e commit: none settings: DATABASE_URL, MAIL_FROM, MAX_DUE_AHEAD, SITE, SMTP_HOST, SMTP_PASSWORD, SMTP_USERNAMEalert: Built from files git doesn't track: no commit explains its eventsbuilt: app, native in 67.1 snext: jadpo migrate apply it starts only once its database has every migration
jadpo build checks the project as jadpo check does, turns it into
Rust and compiles it into one program, build/, with
nothing else to install beside it. The first build fetches the Rust
compiler and libraries it needs, so it takes a few minutes. build: is
an id for this exact build. The alert says the project isn't in git, so
the build can't name the commit it came from; commit your project and it
will. settings: lists variables the program reads.
Get a database
You need a PostgreSQL server the program can reach. On a server, that's
usually one your host runs for you, and it gives you the address. To
try it on your own machine, start one with the PostgreSQL programs from
page 1. This keeps its files in ~/ and listens on port 5433:
initdb -D ~/todo-db -U postgres --auth=trustpg_ctl -D ~/todo-db -o "-p 5433" -l ~/todo-db.log startcreatedb -h localhost -p 5433 -U postgres todos
--auth=trust lets anyone on your machine connect without a password,
which is fine for a trial and never for a real server. Stop it later
with pg_ctl -D ~/.
Put the settings in a file
The program reads its settings from its environment, never from
.env.local. Don't type them on the command line, where your shell's
history keeps them. Put them in a file outside your project, readable
only by you, with your editor. Call it ~/:
~/:
DATABASE_URL=postgres://postgres@localhost:5433/todosSITE=https://todos.example.comSMTP_HOST=smtp.example.comSMTP_USERNAME=todosSMTP_PASSWORD=change-meMAIL_FROM=todos@example.com
chmod 600 ~/todo-app.envReplace each value with your own. Put a value in single quotes if it has spaces or symbols in it. These are the settings without a default, and the ones whose default is only right for your machine. On a real server you'd set the same variables wherever your host keeps secrets.
Apply the migrations
Before the program first runs, and before each new build with new migrations, apply the migrations to the database. This loads the file into your shell's environment for one command:
(set -a; . ~/todo-app.env; jadpo migrate apply)Applied 2 migrations to `main` 20261029T1200_initial applied 20261029T1300_add_sign_in applied
The program refuses to start against a database that's missing one of its migrations.
Run it
(set -a; . ~/todo-app.env; build/target/app/app){"address":"0.0.0.0:8080","build":"b_eae4cf8f9e2e","commit":null,"compiler":"0.1.0","deployable":"app","event":"runtime.started","modified":true,"target":"native","trigger":"native","unbuilt":[],"workers":10}It writes one line of JSON for each thing worth logging. It listens on
port 8080; the variables PORT and HOST change that, read by the
program itself rather than declared as settings.
If a setting it needs is missing, it stops at once, naming the setting
but never printing a value. Here DATABASE_URL was left out:
{"build":"b_eae4cf8f9e2e","commit":null,"compiler":"0.1.0","deployable":"app","event":"runtime.startup_failed","modified":true,"problems":[{"message":"`DATABASE_URL` isn't set: `database_url` is required, so the app can't start without it.","problem":"setting_missing","setting":"database_url","variable":"DATABASE_URL"}],"target":"native"}Every deployed app answers a health check, which a load balancer or your host can poll:
curl -i http://localhost:8080/health/liveHTTP/1.1 200 OKcontent-type: application/jsoncache-control: no-storex-request-id: req_69a12724455206dacontent-length: 17date: Thu, 29 Oct 2026 21:57:41 GMT{"status":"live"}
The routes behave as in jadpo dev:
curl http://localhost:8080/todos{"error":{"code":"authentication_required","message":"Sign in first.","request_id":"req_803740b5e57b78bd"}}Ctrl-c, or the SIGTERM a service manager sends, stops it cleanly: it
finishes the requests it has, then exits. On a real server, put it
behind a proxy that serves HTTPS, since the session cookie is only sent
over HTTPS, and run it under a service manager such as systemd, which
restarts it if it stops.
What you have
The whole app, built into one program and running against a database you control. Next, page 14 deploys the same app to Cloudflare Workers.
Read more in the reference: A native deployable and Database and other connection URLs.
Not built yet
- There are no prebuilt
jadpodownloads yet, so you build on a machine where you builtjadpo, as on page 1. - Counting rate limits in Redis isn't built yet, so a server program counts sign-in emails per address in its own memory. Run one copy of it.
Markdown for agents: /learn/learn-server.md.Built from Jadpo main at a707efb. Every file on this page is compiled and tested by Jadpo's CI, on the app as the pages before it built it.