From 0 to 17,000 users: A meaningful guide to scale apps keeping infrastructure costs low

From 0 to 17,000 users: A meaningful guide to scale apps keeping infrastructure costs low

# nextjs# webdev# devops# architecture
From 0 to 17,000 users: A meaningful guide to scale apps keeping infrastructure costs lowMichael

Whether you're a vibe coder or a software engineer, you may have run into the same problem: your app...

Whether you're a vibe coder or a software engineer, you may have run into the same problem: your app starts getting users, and the monthly bills become a headache. I've had to work through that with anaplirotis.gr.

anaplirotis.gr has grown from zero to more than 17,000 registered users. In the last completed billing month, the application and database used $27.96 in Railway resources.

Around its first public release in early 2025, it had about 2,000 registered users and around 400 weekly active users. Today, it has over 6,200 weekly active users and more than 30,000 visits a week from registered and unregistered users.

It started with a single post in a Facebook group for teachers. I haven't posted another promotional update in that group or similar groups since. Word of mouth and the results teachers get from the app have helped bring in more active users.

In this article

Growth of anaplirotis.gr: about 2,000 to 17,000+ registered users, around 400 to 6,200+ weekly active users, and 30,000+ weekly visits today

Registered users and weekly active users in early 2025 and today. Weekly visits are shown for today only.

I built anaplirotis.gr to help Greek teachers calculate their position in the official waiting lists after the latest hires. The Ministry publishes base tables that cover multiple years, then announces hires in phases during each school year. Those announcements don't update the original tables, so teachers have to work out their position across separate publications.

The app is completely free for teachers, and I don't earn money from their use of it. That's why I try to keep its running costs low.

For example, if you're 100th and the Ministry hires someone ahead of you, your calculated position becomes 99. The official table still says 100. The application brings those publications together so teachers don't have to work through each update manually.

Before moving to Railway, I used Supabase and Vercel. At roughly 1,000–3,000 active users, I started hitting Supabase's free-tier database limits and seeing slowdowns. Vercel then began limiting requests to the server too. I needed more control over the runtime and database at a cost I could maintain.

TL;DR

I keep the app in one Next.js deployment, with PostgreSQL and Drizzle on Railway. The project used $27.96 in resources across its environments for the 22 August–22 September 2026 billing month. The paid Supabase + Vercel setups I priced start at $45 a month, assuming usage stays within the plans' included allowances and credits.

Feature flags let me deploy work before releasing it to users. I use managed services for authentication and notifications, and AI helps with development and code review. I still review and merge changes myself. Keeping the code readable and the running costs manageable matters more to me than shipping every possible feature.

What it costs to run

I checked Railway's usage for the billing month from 22 August to 22 September 2026. The anaplirotis.gr project used $27.96: $12.81 for the application, $15.03 for PostgreSQL, and $0.12 in remaining storage charges. That includes all environments in this project. My other Railway projects are outside these numbers.

For comparison, I checked Supabase and Vercel's prices on 19 October 2026. Vercel Pro starts at $20 a month for one deploying seat, with $20 in usage credit. Supabase Pro starts at $25 a month, including $10 in compute credit. That credit covers one Micro database. Adding a second Micro for staging costs about $10 more a month if it stays running, because the compute credit is shared across the organization.

Monthly setup Supabase Vercel Total
One Micro database $25 $20 $45
Production + staging, both Micro ~$35 $20 ~$55
Small production + Micro staging ~$40 $20 ~$60

Hosting cost comparison: Railway project resource usage of $27.96 for 22 August–22 September 2026, versus monthly Supabase and Vercel paid-plan budgets of $45, $55 with staging, and $60 with a Small production database

Railway shows actual project usage. The other bars are estimated monthly budgets within the plans' included allowances and credits. All amounts are USD before tax. I haven't benchmarked the same workload across these setups.

That puts my Railway resource cost $17.04 below the $45 starting budget, a difference of about 38%. With two Micro databases for production and staging, the difference is about $27. The Railway figure comes from a completed billing month; the alternatives use the plan prices I checked. Supabase bills compute hourly, so its monthly amounts are approximate.

Having 17,000 registered users doesn't tell me which Supabase instance I'd need or what Vercel would charge. That depends on the queries, memory use, caching, traffic, and imports. A larger database or usage beyond the included credits would cost more. I've only counted app hosting and the database here. Clerk, PostHog, Novu, and Resend are all on free tiers for this app. The domain and my own time aren't included in these numbers.

If I reach those free-tier limits, I plan to move to suitable open-source alternatives hosted on Railway. Those services would add to my Railway resource usage.

To make that easier, I want to keep each provider behind a relay layer and use dependency injection to keep it separate from the app's business logic. The app would depend on a shared interface, so I could swap the provider without rewriting the features that use it.

I also take on the database migrations, connection management, backups, and restores myself. For this app, I prefer having that control and keeping the running cost at a level I can maintain.

Keeping the application in one place

I use Next.js and TypeScript, with pages, API routes, server actions, and admin tools in the same codebase. I deploy them together on Railway, so a new module doesn't need a separate service.

I organize newer features into modules such as official-table imports, hire-event ingestion, teacher identity, and membership management. Inside each module, I keep the UI alongside its types, services, and repositories. Routes check permissions and pass requests into the module. Handlers validate the input, services apply business rules, and repositories run database queries.

Actual source folder structure: feature modules under src/modules, an expanded hire-events-ingestion module, shared tools under src, and older shared services and repositories

Selected folders from src/, with hire-events-ingestion expanded. Shared tools sit alongside feature modules, while older shared services and repositories remain in place.

That keeps each feature's UI close to the code that handles its data. I've been introducing this structure feature by feature; older shared services and repositories still coexist with it.

The application modules and their connections to external services

The modules run in one Next.js deployment, with external providers for authentication, analytics, storage, and communication.

Running PostgreSQL with Drizzle

Cost was my main reason for leaving Supabase. I also felt too tied to its platform and SDKs, and wanted more freedom to work with Drizzle ORM, SQL queries, and PostgreSQL functions. Supabase supports Drizzle connections too.

I use a dedicated PostgreSQL 17 service on Railway, connected through Drizzle and the postgres driver. For local development, I run PostgreSQL 17 through Docker Compose.

Drizzle gives me typed schemas and queries, while PostgreSQL handles the relationships, constraints, and transactions the application needs. With this setup, I'm also responsible for migrations, connection management, backups, and restores.

I split imports into parsing, validation, review, and database writes. I keep source files and ingestion records so I can trace a problem back to its input.

I group related writes into transactions and use advisory locks to prevent overlapping imports from interfering with each other. After an import completes, the application updates the affected calculations in batches.

Building an image Railway can run

I'd moved to a paid Vercel subscription, about €20 per month at the time, but was still seeing request throttling and slow responses. I moved the application to Railway alongside the database so I could manage both in the same place.

I use Docker to package the application. The Dockerfile pins the pnpm version, installs dependencies from the lockfile, and builds the application in a separate stage. The final image contains Next.js standalone output, static files, and public assets. It runs Node 22 as a non-root user and includes Poppler for processing official PDF tables.

The pull-request workflow runs lint, a Next.js build, and Vitest before merge. A push to main starts the Docker workflow in GitHub Actions, which publishes the image to GitHub Container Registry with commit, timestamp, and latest tags.

Railway runs the production image published to GitHub Container Registry, with image auto-updates configured. It pulls the selected image through an image update or a manual redeploy and checks application liveness through /api/v1/health. The GitHub workflow stops after publishing the image.

For now, I run database and data migrations separately through manual commands.

Production release and separate staging image build paths

GitHub Actions builds and publishes the images. Railway runs the selected image. I run migrations separately through manual commands.

Staging uses a manually dispatched build for a selected branch, commit, or pull-request reference, with staging configuration and a test Clerk key. Its Railway service selects a versioned staging tag. Publishing a new mutable alias alone won't update that service.

Next.js embeds public environment variables in the browser bundle at build time, so I supply them during the Docker build. Server credentials live in Railway runtime variables. Changing a runtime variable won't change a public value already compiled into the image.

In my earlier article about runtime environment variables, I passed selected runtime configuration to the browser so one image could serve several environments. For anaplirotis.gr, staging uses development or test service configuration for Clerk, PostHog, and Novu. I build public configuration separately for each environment so test traffic stays apart from live services. That gives me a production-like place to test data migrations and unreleased features.

Managing releases with trunk-based development

I use trunk-based development and keep ongoing work integrated into main. With PostHog feature flags, I can merge and deploy feature code before making it available to users. When I'm ready to release it, I turn on the flag.

If a flagged feature causes problems, I can turn it off while I investigate. I don't have to push a hotfix just to hide it.

During the schema migration, I used a feature flag to switch a relay controller between the old and new Drizzle repositories. I could choose which repository handled requests while I worked through the migration. The schema and data changes still ran through separate commands.

During the v2 release, I also ran experiments. I've retired those checks, but kept the interaction events for historical reports.

Moving DNS to Cloudflare

I moved DNS management from Papaki to Cloudflare because managing records in Papaki's UI had become frustrating. I also wanted better traffic visibility and security controls in the same place.

Cloudflare's UI, analytics, and free tier suited the project. It handles DNS and proxies application traffic to Railway. DDoS protection and Bot Fight Mode are available on the Free plan; other controls depend on the plan.

Clerk's required CNAMEs must remain DNS-only, and email delivery needs sender-domain verification.

Using Clerk for authentication and account management

I use Clerk for sign-in, sessions, and account profiles. Clerk manages account emails, so I don't maintain a separate email field in my application's user table.

Clerk supports Google and Microsoft social sign-in. It also offers prebuilt sign-in, waitlist, and profile-management components that I can style to fit the rest of the application.

Signed webhooks keep account changes in sync with the application. Clerk's free Hobby plan currently includes 50,000 monthly retained users per application. That counts users who return at least 24 hours after signing up, rather than total registrations.

Clerk integration: Google and Microsoft social sign-in, prebuilt account components, sessions, and account synchronization with anaplirotis.gr

Clerk handles authentication and account emails, with prebuilt UI components and verified account updates for the application.

AI workflows I use day to day

Every Saturday, an orchestrator agent checks PostHog for issues and assigns sub-agents to propose fixes as pull requests. It reports back to me for review, approval, and merge. I'll cover that workflow, along with how I use PR-Agent, Notion, and Figma, in a follow-up article.

Sending updates after the import succeeds

After importing a new hire phase, I need to let teachers know. Novu handles notification workflows and the in-app inbox. The subscriber and embedded inbox use the same Clerk user ID. The application triggers welcome and table-update workflows, passing details such as the hire phase and affected categories.

The application sends updates after the import succeeds. A notification failure leaves the imported data intact, so I can investigate delivery separately. The admin email-broadcast tool checks notification preferences, avoids duplicate recipients, and includes signed unsubscribe links.

Resend handles email delivery. For support emails, the application uses Nodemailer with Resend's SMTP endpoint.

Keeping control as the codebase grows

AI helps me work on several tasks in parallel, but it also produces code faster than I can review it. I'm becoming the bottleneck. I use it to help clean up and organize the code too, so I can keep up with what I'm shipping.

For now, I care more about keeping control of the codebase than releasing features as fast as possible. Code I can't easily read or change will slow down later releases. And adding a feature teachers don't need only gives me more code to maintain.

I used AI to help draft and edit the text and create the diagrams. The project decisions and experiences are my own.

Note: This article isn't sponsored. None of the companies behind the products I mention are paying me to write about them. I'm sharing my own experience using them.