Skip to content

Free prelaunch hosting

Vitni’s prelaunch deployment is a $0 demonstration environment. It is not a production availability commitment. The providers can sleep, rate-limit, pause, or change free-tier limits. No Certo users, credentials, signing keys, or customer records are migrated.

HostnameServiceProvider
certificate.vitni.ccMarketing site, workspace, badge verificationCloudflare Workers Free
docs.certificate.vitni.ccAstro/Starlight documentationCloudflare Workers Free static assets
api.certificate.vitni.ccNestJS API and OpenAPIRender Free web service
OIDC issuer URLHosted login and identityZITADEL Cloud Free generated domain
Scheduled email deliveryOutbox processorCloudflare Workers Free + Hyperdrive + Resend Free
PostgreSQLApplication dataNeon Free, EU region

The diagram and architecture decisions are recorded in the deployment design.

  1. Create a Neon Free project in an EU region. Use its default database, neondb.

  2. Create service roles through the Neon SQL Editor, not the Neon Console, API, or CLI. Neon grants Console-created roles membership in neon_superuser, which bypasses RLS and carries database/role administration privileges. The SQL-created roles below stay separate from that administrator role. See Neon’s role guidance.

    CREATE ROLE vitni_migrator_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOINHERIT;
    CREATE ROLE vitni_app_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOINHERIT;
    CREATE ROLE vitni_worker_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOINHERIT;
    GRANT CONNECT ON DATABASE neondb TO vitni_migrator_runtime, vitni_app_runtime, vitni_worker_runtime;
    GRANT CREATE ON DATABASE neondb TO vitni_migrator_runtime;
    GRANT USAGE, CREATE ON SCHEMA public TO vitni_migrator_runtime;
    GRANT USAGE ON SCHEMA public TO vitni_app_runtime, vitni_worker_runtime;
    ALTER DEFAULT PRIVILEGES FOR ROLE vitni_migrator_runtime IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO vitni_app_runtime;
    ALTER DEFAULT PRIVILEGES FOR ROLE vitni_migrator_runtime IN SCHEMA public
    GRANT USAGE, SELECT ON SEQUENCES TO vitni_app_runtime;

    For an existing Vitni database whose objects are still owned by the old Console-created vitni_migrator, grant that role temporary membership before applying migration 0021_runtime_role_tightening:

    GRANT vitni_migrator_runtime TO vitni_migrator;

    After the migration transfers object ownership to vitni_migrator_runtime, remove the temporary membership:

    REVOKE vitni_migrator_runtime FROM vitni_migrator;

    Do not put passwords in SQL Editor history. After creating the SQL roles, use Neon Console’s password-reset action for each role and set the passwords yourself, then copy each connection URL. Keep the existing Neon Console roles (neondb_owner, vitni_app, vitni_worker, and vitni_migrator) out of Render and Cloudflare.

  3. Copy .env.production.example to the ignored .env.production.local. Put the three SQL-role URLs there, using Neon TLS and the non-pooled connection string for migrations. Keep this file private.

  4. Run the fresh-schema migrations once:

    Terminal window
    corepack pnpm db:migrate:production

Use the pooled vitni_app_runtime URL for Render’s API if needed. For Hyperdrive, use the direct vitni_worker_runtime URL: Hyperdrive supplies the connection pool. Never put an owner or Console-created Neon role URL in Render or Cloudflare.

  1. Use the configured ZITADEL Cloud instance https://vitni-r5stjz.eu1.zitadel.cloud and Vitni project 397023330298777565.
  2. The web app Vitni Web uses client ID 397023387190330854, Authorization Code with PKCE (S256), and no client secret.
  3. Add the exact redirect URL https://certificate.vitni.cc/auth/callback and post-logout URL https://certificate.vitni.cc/.
  4. Enable refresh tokens, set the web application’s access token type to JWT, and enable user information inside the ID token for the web session.
  5. Set the token audience to the Vitni project. Its project ID is 397023330298777565; the matching audience scope is urn:zitadel:iam:org:project:id:397023330298777565:aud.
  6. The generated ZITADEL domain is used for login; custom login domains require a paid ZITADEL plan.

Set these values in Render and the web Worker’s public variables:

VITNI_OIDC_ISSUER=https://vitni-r5stjz.eu1.zitadel.cloud
VITNI_OIDC_AUDIENCE=397023330298777565
NUXT_OAUTH_ISSUER=https://vitni-r5stjz.eu1.zitadel.cloud
NUXT_OAUTH_CLIENT_ID=397023387190330854
NUXT_OAUTH_REDIRECT_URL=https://certificate.vitni.cc/auth/callback
NUXT_OAUTH_POST_LOGOUT_REDIRECT_URL=https://certificate.vitni.cc/
NUXT_OAUTH_SCOPE=openid profile email offline_access
NUXT_OAUTH_API_AUDIENCE_SCOPE=urn:zitadel:iam:org:project:id:397023330298777565:aud

The API validates the ZITADEL JWT access token through discovered JWKS, then uses the discovered UserInfo endpoint to obtain verified email claims. This is needed because ZITADEL keeps email claims out of JWT access tokens. Vitni’s API still enforces its own organization membership and role checks; ZITADEL organizations do not replace Vitni’s tenant authorization model. For local development, .env keeps the local Keycloak issuer; self-hosters can set any compatible OIDC issuer, client ID, and audience.

The root render.yaml defines one Frankfurt Docker web service using infra/render/api.Dockerfile, the /api/v1/health readiness check, and the api.certificate.vitni.cc custom domain. The separate GitHub repository is private while source reuse and licensing are reviewed. Connect it in Render and apply the Blueprint after the repository access is approved.

Set these secret environment values in Render:

  • VITNI_DATABASE_URL: the vitni_app_runtime SQL-role connection URL.
  • VITNI_SIGNING_KEY_ENCRYPTION_KEY: a newly generated 32-byte key encoded as 64 hexadecimal characters. Keep a secure recovery copy; never reuse a Certo key.
  • VITNI_OIDC_ISSUER and VITNI_OIDC_AUDIENCE: the ZITADEL issuer and Vitni backend project ID.

The service binds to Render’s PORT and 0.0.0.0. Add the DNS record Render shows for its custom domain in Cloudflare. Leave it DNS-only while Render provisions TLS; proxy it only after the domain is verified.

Wrangler deploys only the web app, docs, and scheduled email Worker. Cloudflare Containers are removed from the deployment path.

  1. Set NUXT_OAUTH_ISSUER, NUXT_OAUTH_CLIENT_ID, the redirect URLs, scope, and audience scope in apps/web/wrangler.jsonc before deploying the site.

  2. Create a fresh NUXT_SESSION_PASSWORD with at least 32 random characters and set it as a secret on apps/web/wrangler.jsonc.

  3. Create a Hyperdrive configuration in Cloudflare pointing to the Neon vitni_worker_runtime SQL role. Add its ID as the HYPERDRIVE binding in infra/cloudflare/email-worker/wrangler.jsonc. Store only the worker role’s connection in Hyperdrive; do not put it in Worker vars.

  4. Verify a sender domain in Resend. Add its SPF/DKIM records in Cloudflare, set the VITNI_EMAIL_FROM Worker variable, and put RESEND_API_KEY and the same new signing-key encryption secret into the email Worker’s secret store.

  5. Keep VITNI_EMAIL_DELIVERY_ENABLED set to false until the sender, Hyperdrive binding, and secrets are ready. The Worker claims at most five queued messages per five-minute run and sends with a per-outbox idempotency key.

  6. Deploy the Worker assets:

    Terminal window
    corepack pnpm deploy:cloudflare

The email Worker uses the existing encrypted email_outbox format. Its Node/SMTP runner remains available for local and self-hosted use; Cloudflare uses the Resend HTTPS API because Workers and free Render services do not support outbound SMTP delivery.

  • Cloudflare Workers Free currently permits 100,000 requests per day, 10 ms CPU per invocation, and five Cron Triggers per account. Hyperdrive has its own free-tier query allowance. Check the account’s existing Cron Trigger use before adding the schedule.
  • Render Free web services sleep after 15 idle minutes and can take about a minute to wake. They share 750 free instance hours per workspace each month. Render says Free is for testing and hobby use, not production.
  • Neon Free currently provides 1 GB per project and 100 compute-hours per month; compute scales to zero.
  • ZITADEL Cloud Free currently allows 100 daily active users on one instance and does not include a custom domain or availability SLA.
  • Resend Free currently includes 3,000 emails per month and a 100-per-day cap.

Do not add keep-alive traffic or paid overages. If the prelaunch limits become insufficient, stop and review a paid production architecture before upgrading any service.

Terminal window
curl -fsS https://api.certificate.vitni.cc/api/v1/health
curl -fsS https://<instance>.zitadel.cloud/.well-known/openid-configuration

Open the site and test this sequence with a fresh account: sign in, create an organization, configure its issuer, author a badge, issue a credential, and verify the public credential. Enable email only after Resend sender verification; then issue one test credential to confirm claim-email delivery.

Check the docs at https://docs.certificate.vitni.cc and API contract at https://api.certificate.vitni.cc/api/v1/openapi.