DEV Community

Marc Pomar
Marc Pomar

Posted on AI-assisted

I built a PaaS with its own OAuth server. Here's a deploy and a login, start to finish

Hi, I'm Marc. For the last couple of years I've been building Faable, a platform where you deploy an app and, if it has users, get an OAuth 2.0 / OIDC server for them in the same account. It's hosted in the EU.

Instead of a feature list, here is what using it actually looks like: an Express app deployed from the terminal, then a login added to a frontend. Every command below is copied from our docs, and we timed the deploys on a fresh account this week.

1. Deploy an Express app from your terminal

No repository needed. Start with the smallest Express app there is:

mkdir hello-express && cd hello-express
npm init -y
npm install express
Enter fullscreen mode Exit fullscreen mode
// index.js
const express = require('express')

const app = express()
const port = process.env.PORT

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, '0.0.0.0', () => {
  console.log(`Listening on port ${port}`)
})
Enter fullscreen mode Exit fullscreen mode

Add a start script to package.json:

"scripts": {
  "start": "node index.js"
}
Enter fullscreen mode Exit fullscreen mode

Then log in and deploy:

npx @faable/faable login
npx @faable/faable deploy launch --create hello-express
Enter fullscreen mode Exit fullscreen mode

login uses a device flow, so it opens your browser and you approve it there. deploy launch --create makes the app and deploys the current folder to it. Three things happen:

  1. Upload. The CLI snapshots the folder and uploads only the files the platform hasn't seen before, so the next deploy only sends what changed. node_modules and .env files stay on your machine.
  2. Remote build. The builder sees a package.json, installs dependencies and runs your build step if you have one. Nothing builds locally: no Docker, no toolchain on your laptop.
  3. Release. The app goes live at https://<app>.faable.link with TLS, and the CLI waits until the URL actually answers before it tells you it's done.

On a fresh account this took about 50 seconds for this app. A Flask app took about 90.

It runs as a normal long-lived process, not a serverless function: it starts once, keeps its state in memory while it's up, and a background loop keeps running between requests.

The bug that was in our own docs

Look at the app.listen line. Until this week, our Express guide had the arguments the other way round: app.listen('0.0.0.0', port, ...).

That version doesn't crash. It prints "Listening", and then never answers a request, because Node treats a non-numeric first argument as a path and opens a Unix socket file called 0.0.0.0 instead of a TCP port. It's the kind of bug that eats an afternoon, so if you have a container that "starts fine" and never responds, check the order: port first, then host.

2. Or deploy on every push

If your code is on GitHub or GitLab, connect the repo from the dashboard and every push to the default branch builds and goes live. The deploy status shows up on the commit. Same builder, same detection: Node.js (Next.js, Express, Fastify, NestJS, Astro, Vite), Python (Django, FastAPI, Flask) and PHP (Laravel or plain PHP).

3. Add a login

This is the part I care most about, because it's the part I kept rebuilding on every project.

In the dashboard you create a Client for your frontend and set its callback URL. Then, in a plain JavaScript single-page app:

npm install @faable/auth-js
Enter fullscreen mode Exit fullscreen mode
// src/auth.ts
import { createClient } from '@faable/auth-js'

export const auth = createClient({
  domain: 'your-domain.auth.faable.link',
  clientId: 'YOUR_CLIENT_ID',
  redirectUri: window.location.origin + '/callback'
})
Enter fullscreen mode Exit fullscreen mode
// src/main.ts
import { auth } from './auth'

const app = document.querySelector<HTMLDivElement>('#app')!

async function render() {
  if (location.pathname === '/callback') {
    const { error, returnTo } = await auth.handleRedirectCallback()
    if (error) return void (app.textContent = `Sign-in failed: ${error.message}`)
    return void location.replace(returnTo ?? '/')
  }

  const { data } = await auth.getSession()
  if (!data.session) {
    app.innerHTML = `<button id="login">Sign in</button>`
    document.querySelector('#login')!
      .addEventListener('click', () => auth.signInWithOauthConnection({}))
    return
  }

  app.innerHTML = `<p>Hello ${data.session.user.email}</p>`
}

auth.onAuthStateChange(() => render())
render()
Enter fullscreen mode Exit fullscreen mode

That's the Authorization Code flow with PKCE. The sign-in button sends the user to a hosted login page with whatever you've switched on: Google, GitHub, passwords, and on paid plans email codes and MFA. Your app gets back a session with an access token you can send to your API as a Bearer token.

It's a standard OAuth 2.0 / OIDC server, so you don't have to use our SDK or host the app with us. Anything that speaks OIDC can use it, and the same server issues machine-to-machine tokens for service-to-service calls.

Things that might bite you

I'd rather you read these here than find them yourself:

  • Free apps sleep after 30 minutes without traffic. The first request after that takes about 5 seconds while the app wakes up. On Hobby they sleep after 2 hours.
  • New accounts can create 2 apps in their first 24 hours. It's an abuse limit. The error tells you when you can create the next one.
  • Your app has to listen on $PORT. And, see above, in the right order.

Pricing

Free needs no card: apps sleep after 30 minutes, and you get 10 GB of egress a month. Hobby is €15/month: apps stay up for 2 hours without traffic, and Dockerfile builds are included. Pro is €99/month. Pricing is per project, never per user.

What I'd like to know

How long does it take you from sign-up to a live URL, and where do you get stuck? That first deploy is the thing I'm working on most right now, and I read every comment.

Top comments (0)