Build a Todo App¶
A complete, end-to-end tutorial: a todo-list app from paas apps create to a custom domain with HTTPS, scaled, observable, and CI-deployed. Estimated time: 45 minutes.
The tutorial uses Express.js as the canonical example with Python Flask snippets in tabs where relevant.
What you'll build¶
A 3-screen todo app:
GET /— list todosPOST /— add a todoPOST /:id/delete— delete a todo
Backed by a managed PostgreSQL, served on a custom domain with auto-TLS, scaled to 2 replicas, deployed via GitHub Actions.
Prerequisites¶
- Node.js 20+ (
node --version) - The
paasCLI: - A domain you control (for Step 7) — optional
Step 1 — Scaffold the app¶
Create the project + Express skeleton + Procfile:
mkdir todo-app && cd todo-app
npm init -y
npm install express ejs pg express-session connect-pg-simple
cat > app.js <<'EOF'
const express = require('express');
const app = express();
app.set('view engine', 'ejs');
app.use(express.urlencoded({ extended: false }));
app.get('/', (_, res) => res.send('Hello, todo!'));
app.listen(process.env.PORT || 3000, () => {
console.log('listening on', process.env.PORT || 3000);
});
EOF
echo "web: node app.js" > Procfile
echo "node_modules" > .gitignore
git init && git add -A && git commit -m "init"
Create the PaaS app:
You now have:
- A namespace + Forgejo repo + TLS cert + initial deploy slot
- A
paasgit remote in your local repo https://todo-app.runtime.di2amp.comreserved (404 until first push)
Step 2 — Add the database¶
Output:
✓ Provisioning postgres (starter)
Cluster: pg-todo-db-7d92
Version: PostgreSQL 16.2
Memory: 512Mi
Storage: 5Gi
Backups: nightly
✓ Bound to app todo-app as DATABASE_URL
✓ App will redeploy with the new env var
Verify:
$ paas config | grep DATABASE
DATABASE_URL=postgres://user:••••@pg-todo-db-7d92.svc:5432/todos
$ paas addons:psql todo-db
todos=# CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
todos=# \q
Step 3 — Model + routes¶
Replace app.js:
const express = require('express');
const session = require('express-session');
const pgSession = require('connect-pg-simple')(session);
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.set('view engine', 'ejs');
app.use(express.urlencoded({ extended: false }));
app.use(session({
store: new pgSession({ pool, createTableIfMissing: true }),
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax' },
}));
// List todos
app.get('/', async (_, res) => {
const { rows } = await pool.query(
'SELECT id, title, done FROM todos ORDER BY created_at DESC'
);
res.render('index', { todos: rows });
});
// Add a todo
app.post('/', async (req, res) => {
const title = (req.body.title || '').trim();
if (title) {
await pool.query('INSERT INTO todos (title) VALUES ($1)', [title]);
}
res.redirect('/');
});
// Toggle done
app.post('/:id/toggle', async (req, res) => {
await pool.query('UPDATE todos SET done = NOT done WHERE id = $1', [req.params.id]);
res.redirect('/');
});
// Delete
app.post('/:id/delete', async (req, res) => {
await pool.query('DELETE FROM todos WHERE id = $1', [req.params.id]);
res.redirect('/');
});
// Health
app.get('/healthz', (_, res) => res.json({ ok: true }));
app.listen(process.env.PORT || 3000);
Step 4 — Auth (session secret)¶
The next deploy injects it as process.env.SESSION_SECRET. The fallback 'change-me' in app.js is only for local dev.
Step 5 — Frontend (EJS template)¶
mkdir views
cat > views/index.ejs <<'EOF'
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Todo on PaaS</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 16px; }
h1 { margin: 0 0 24px; font-size: 1.5rem; }
form.add { display: flex; gap: 8px; margin-bottom: 24px; }
form.add input { flex: 1; padding: 8px 12px; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; }
form.add button { padding: 8px 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #eee; }
li.done span.title { text-decoration: line-through; color: #999; }
.title { flex: 1; }
button { background: #4a5568; color: white; border: 0; padding: 4px 10px; border-radius: 4px; cursor: pointer; }
button.delete { background: #e53e3e; }
</style>
</head>
<body>
<h1>📝 Todo</h1>
<form class="add" method="post" action="/">
<input name="title" placeholder="What needs doing?" autofocus required>
<button type="submit">Add</button>
</form>
<ul>
<% todos.forEach(function(t) { %>
<li class="<%= t.done ? 'done' : '' %>">
<form method="post" action="/<%= t.id %>/toggle">
<button type="submit"><%= t.done ? '↩' : '✓' %></button>
</form>
<span class="title"><%= t.title %></span>
<form method="post" action="/<%= t.id %>/delete">
<button type="submit" class="delete">✕</button>
</form>
</li>
<% }); %>
</ul>
</body>
</html>
EOF
git add -A && git commit -m "feat: full todo app"
Step 6 — First deploy¶
Watch the rollout:
remote: ─────── PaaS Build ───────
remote: → Detected: nodejs (paketo-buildpacks/nodejs)
remote: → Running: npm ci --omit=dev
remote: → Image: registry.di2amp.com/octave/todo-app:abc1234
remote:
remote: ─────── PaaS Deploy ───────
remote: → Replicas: 1/1 ready
remote: → Health: /healthz returns 200 OK
remote:
remote: ✓ Deployed in 41s
Verify:
$ curl -i https://todo-app.runtime.di2amp.com/
HTTP/2 200
content-type: text/html
...
<h1>📝 Todo</h1>
Open in your browser, add a todo, refresh — it persists (PostgreSQL).
Step 7 — Custom domain + auto-TLS¶
If you own example.com, add a subdomain pointing to PaaS:
Output:
✓ Domain todo.example.com queued
Add this DNS record to verify ownership:
todo.example.com CNAME ingress.runtime.di2amp.com.
Or for apex domains, ALIAS / ANAME / A 51.158.x.x
Add the CNAME via your DNS provider, then:
$ paas domains:verify todo.example.com
✓ DNS resolves to PaaS ingress
✓ cert-manager issued Let's Encrypt cert (todo.example.com)
✓ Domain LIVE
cert-manager auto-renews 30 days before expiry. No action needed.
Step 8 — Scale + monitor¶
Scale to 2 replicas (HA, no downtime on rollouts):
Output:
Watch metrics:
Or open the Grafana dashboard:
https://ma30.di2amp.com/grafana/d/app-todo-app
Step 9 — CI/CD¶
Wire GitHub Actions for auto-deploys on push to main. See Cookbook → GitHub Actions CI/CD for the full workflow file. Quick version:
paas tokens:create --name "todo-ci" --ttl 90d # save the PAAS_TOKEN
gh secret set PAAS_TOKEN --body "paas_pat_..."
gh secret set PAAS_APP_NAME --body "todo-app"
mkdir -p .github/workflows
cat > .github/workflows/deploy.yml <<'EOF'
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: curl -fsSL https://get.di2amp.com/install.sh | sh
- env:
PAAS_TOKEN: ${{ secrets.PAAS_TOKEN }}
PAAS_APP: ${{ secrets.PAAS_APP_NAME }}
run: paas deploy --wait
EOF
git add .github && git commit -m "ci: github actions" && git push paas main
Now every push to main from any contributor (with proper GitHub access) triggers an automated deploy.
Step 10 — Clean up¶
When you're done experimenting:
Both are soft-deleted for 30 days (recoverable via paas apps:restore and paas addons:restore).
What you learned¶
- ✅ Provisioned an app with a managed addon, env vars, and secrets
- ✅ Built a full CRUD web app with sessions backed by PostgreSQL
- ✅ Deployed via
git push paas mainwith rolling rollout + health checks - ✅ Attached a custom domain with auto-issued TLS
- ✅ Scaled horizontally (2 replicas) and observed metrics
- ✅ Wired GitHub Actions for continuous deployment
Where to go next¶
- Concepts → Apps — deeper dive on the app model
- Concepts → Add-ons — Valkey, OpenSearch
- Guides → Scale Your App — autoscaling (HPA), burst capacity
- Guides → Troubleshooting — common errors
- Cookbook → Stripe Webhook — handle async events
- Reference → paas.toml schema — declarative config