Skip to content

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 todos
  • POST / — add a todo
  • POST /: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 paas CLI:
    curl -fsSL https://get.di2amp.com/install.sh | sh
    paas login
    
  • 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:

paas apps create todo-app

You now have:

  • A namespace + Forgejo repo + TLS cert + initial deploy slot
  • A paas git remote in your local repo
  • https://todo-app.runtime.di2amp.com reserved (404 until first push)

Step 2 — Add the database

paas addons:create database --type postgres --plan starter --name todo-db

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)

paas secrets:set SESSION_SECRET="$(openssl rand -base64 32)"

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

git push paas main

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:

paas domains:add todo.example.com

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):

paas ps:scale web=2

Output:

✓ web: 1 → 2 replicas (rolling, 30s)

Watch metrics:

$ paas metrics --range 1h
PROCESS  CPU   MEM     RPM    P95 LAT
web      18%   142Mi   245    32ms

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:

paas addons:destroy todo-db --yes
paas apps:delete todo-app --yes

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 main with 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