DEV Community

Cover image for Deploy Next.js to GCP for FREE: Serverless Setup with SQLite
4484-ho
4484-ho

Posted on

Deploy Next.js to GCP for FREE: Serverless Setup with SQLite

Introduction

When deploying personal projects or small-scale applications to GCP (Google Cloud Platform), you want to keep costs low while avoiding complex configurations. This article introduces a simple and practical GCP deployment setup that runs for $0-5/month (effectively $0 when using the free tier).

This article explains how to achieve a cost-effective, maintenance-free setup using the Next.js BFF pattern combined with SQLite + Cloud Storage. Throughout this guide, we'll use a vocabulary learning web application as a practical example to demonstrate the concepts and implementation.

Features of This Setup

  • Single Container Architecture: Frontend and backend run in one container
  • SQLite Database: No additional DB server required, reducing costs
  • No Maintenance Required: Serverless operation with Cloud Run
  • Auto-scaling: Automatically scales based on traffic (can scale down to 0)
  • TypeScript Throughout: Type safety between frontend and backend
  • Generous Free Tier: Free up to 2 million requests per month

Architecture Overview

┌─────────────────────────────────────┐
│   Cloud Run Service                 │
│                                     │
│   ┌─────────────────────────────┐  │
│   │  Next.js Container (BFF)    │  │
│   │  - Frontend (React)         │  │
│   │  - API Routes (Backend)     │  │
│   └─────────────────────────────┘  │
│            │                        │
│            ▼                        │
│   ┌─────────────────────────────┐  │
│   │  Cloud Storage              │  │
│   │  └─ vocabulary.db (SQLite)   │  │
│   │     (Download on startup /   │  │
│   │      Upload on shutdown)     │  │
│   └─────────────────────────────┘  │
└─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

What is the BFF (Backend for Frontend) Pattern?

This pattern leverages Next.js's App Router to integrate the frontend and API routes within the same application.

Benefits:

  • Simple deployment with everything in one container
  • Type safety between frontend and backend (unified TypeScript)
  • No need for additional API gateways or load balancers

Structure Example:

/app
  ├── page.tsx          # Home page
  ├── register/page.tsx # Registration page
  ├── exercise/page.tsx # Exercise page
  └── api/
      ├── cards/route.ts    # Card CRUD API
      └── reviews/route.ts  # Learning history API
Enter fullscreen mode Exit fullscreen mode

Technology Stack Selection Rationale

Next.js (App Router)

Why Next.js:

  • Can integrate frontend and API (BFF pattern)
  • Supports Server-Side Rendering (SSR) and Static Site Generation (SSG)
  • Can leverage TypeScript's type safety
  • Rich ecosystem

SQLite

Why SQLite:

  • No Additional DB Server: No need for managed DB services like Cloud SQL
  • File-based: Easy to set up
  • Cost Reduction: No DB server fees
  • Unified with Local Development: Can use the same DB in both development and production

Considerations:

  • Constraints on concurrent writes from multiple instances
  • Not suitable for large-scale data or multi-user scenarios
  • Consider migrating to PostgreSQL (Cloud SQL) in such cases

Cloud Run

Why Cloud Run:

  • Serverless: No server management required
  • Auto-scaling: Automatically scales based on traffic (can scale down to 0)
  • Request-based Billing: No charges when there are no requests
  • Generous Free Tier: Free up to 2 million requests per month
  • CI/CD Integration: Can automate deployment with Cloud Build or GitHub Actions

GCP Deployment Configuration Comparison

Recommended: Cloud Run + Cloud Storage

Configuration:

  • Cloud Run service (container-based, serverless)
  • Store SQLite database file in Cloud Storage
  • Download from Cloud Storage on startup, upload on shutdown

Cost Estimate:

  • Cloud Run: $0 within free tier, approximately $0.40 per million requests when exceeded
  • Cloud Storage: Approximately $0.020/GB/month (storage)
  • Total: Effectively $0/month within free tier, approximately $0-5/month when exceeded

Benefits:

  • No maintenance required
  • Request-based billing (no charges when there are no requests)
  • Auto-scaling support (can scale down to 0)
  • Generous free tier

Alternative 1: Compute Engine e2-micro (Free Tier)

Configuration:

  • Compute Engine e2-micro (using free tier)
  • Run container with Docker
  • Persist SQLite data with persistent disk

Cost Estimate:

  • Using Free Tier: Effectively $0/month (up to 750 hours per month)
  • After free tier expires: Approximately $6-7/month
  • Persistent Disk: Approximately $0.04/GB/month

Benefits:

  • Lowest cost (when using free tier)
  • Full control possible
  • Can run continuously

Drawbacks:

  • Maintenance required (OS patches, etc.)
  • Scaling is manual configuration
  • Free tier is up to 750 hours per month (about one month of continuous operation)

Alternative 2: Cloud Run + Cloud SQL

Cost Estimate: Approximately $10-15/month (minimum configuration)

Benefits:

  • Multi-user support
  • Data sharing possible between multiple instances
  • Automatic backups

Drawbacks:

  • Higher cost
  • Overkill for small projects

Alternative 3: Cloud Run + Cloud Filestore

Cost Estimate: Approximately $20-30/month (minimum configuration)

Benefits:

  • NFS-compatible file system
  • Data sharing possible between multiple instances

Drawbacks:

  • Higher cost
  • Cannot mount directly from Cloud Run (requires Compute Engine)

Implementation Points for SQLite + Cloud Storage

Data Persistence Mechanism

Since Cloud Run is stateless, it cannot directly save SQLite files. Therefore, we adopt a method of storing SQLite files in Cloud Storage, downloading them on startup and uploading them on shutdown.

Cloud Run Container (on startup)
  └─ Download SQLite file from Cloud Storage
  └─ Save to /tmp/vocabulary.db
  └─ Run application

Cloud Run Container (on shutdown)
  └─ Upload /tmp/vocabulary.db to Cloud Storage
Enter fullscreen mode Exit fullscreen mode

Implementation Points

1. Cloud Storage Configuration

Bucket Creation:

  1. Create a Cloud Storage bucket in the GCP console
  2. Select a region (recommended: same region as Cloud Run)
  3. Configure access control (grant read/write permissions to Cloud Run service account)

2. Database Connection Implementation

// lib/db/index.ts
import Database from 'better-sqlite3';
import { Storage } from '@google-cloud/storage';
import fs from 'fs';
import path from 'path';

const storage = new Storage();
const bucketName = process.env.GCS_BUCKET_NAME || 'vocabulary-app-db';
const dbFileName = 'vocabulary.db';
const localDbPath = `/tmp/${dbFileName}`;
const gcsDbPath = `data/${dbFileName}`;

// Download SQLite file from Cloud Storage
async function downloadDatabase() {
  try {
    const bucket = storage.bucket(bucketName);
    const file = bucket.file(gcsDbPath);

    // Check if file exists
    const [exists] = await file.exists();
    if (exists) {
      await file.download({ destination: localDbPath });
      console.log('Database downloaded from Cloud Storage');
    } else {
      // Create empty database on first startup
      console.log('Creating new database');
    }
  } catch (error) {
    console.error('Error downloading database:', error);
    // Create empty database on error
  }
}

// Upload SQLite file to Cloud Storage
async function uploadDatabase() {
  try {
    const bucket = storage.bucket(bucketName);
    const file = bucket.file(gcsDbPath);

    if (fs.existsSync(localDbPath)) {
      await file.save(fs.readFileSync(localDbPath), {
        metadata: { contentType: 'application/x-sqlite3' },
      });
      console.log('Database uploaded to Cloud Storage');
    }
  } catch (error) {
    console.error('Error uploading database:', error);
  }
}

// Download database on application startup
await downloadDatabase();

// Database connection
export const db = new Database(localDbPath);

// Upload database on process termination
process.on('SIGTERM', async () => {
  await uploadDatabase();
  process.exit(0);
});

process.on('SIGINT', async () => {
  await uploadDatabase();
  process.exit(0);
});

// Periodic backup (optional)
setInterval(async () => {
  await uploadDatabase();
}, 5 * 60 * 1000); // Every 5 minutes
Enter fullscreen mode Exit fullscreen mode

3. Local Development Environment

For local development, use the local file system instead of Cloud Storage:

// lib/db/index.ts (for local development)
const isProduction = process.env.NODE_ENV === 'production';
const dbPath = isProduction 
  ? `/tmp/vocabulary.db`
  : './data/vocabulary.db';

export const db = new Database(dbPath);
Enter fullscreen mode Exit fullscreen mode

4. Environment Variable Configuration

# .env.local (local development)
DATABASE_PATH=./data/vocabulary.db

# Cloud Run environment variables
GCS_BUCKET_NAME=vocabulary-app-db
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
Enter fullscreen mode Exit fullscreen mode

Important Considerations

  1. Concurrent Writes with Multiple Instances

    • SQLite has constraints on concurrent writes from multiple processes
    • Cloud Run may start multiple instances
    • Solutions:
      • Set Cloud Run's maximum instances to 1
      • Or migrate to Cloud SQL (PostgreSQL)
  2. Increased Startup Time

    • Downloading from Cloud Storage takes time (several seconds)
    • May increase response time for the first request
  3. Data Loss Risk

    • If the process terminates abnormally, the latest data may not be uploaded
    • Solution: Implement periodic backups (e.g., every 5 minutes)
  4. Performance

    • SQLite is optimal for small-scale data
    • Consider migrating to PostgreSQL for large-scale data or high traffic

Cost Calculation (Specific Examples)

Scenario 1: Within Free Tier (Personal Development / Small Projects)

Configuration: Cloud Run + Cloud Storage

Item Monthly Cost
Cloud Run (up to 2 million requests) $0
Cloud Storage (1GB) $0.02
Total Approximately $0.02/month (effectively $0)

Scenario 2: Exceeding Free Tier (Medium Traffic)

Configuration: Cloud Run + Cloud Storage

Item Monthly Cost
Cloud Run (5 million requests) $1.20
Cloud Storage (5GB) $0.10
Total Approximately $1.30/month

Scenario 3: Using Compute Engine Free Tier

Configuration: Compute Engine e2-micro (Free Tier) + Persistent Disk

Item Monthly Cost
Compute Engine e2-micro (Free Tier) $0
Persistent Disk (20GB) $0.80
Total Approximately $0.80/month (approximately $6.80/month after free tier expires)

Implementation Flow

1. Project Setup

# Create Next.js project
npx create-next-app@latest vocabulary-app --typescript

# Install required packages
npm install better-sqlite3 drizzle-orm @google-cloud/storage
npm install -D drizzle-kit @types/better-sqlite3
Enter fullscreen mode Exit fullscreen mode

2. Database Schema Definition

// lib/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const cards = sqliteTable('cards', {
  id: text('id').primaryKey(),
  content: text('content').notNull(),
  description: text('description').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});

export const reviewHistory = sqliteTable('review_history', {
  id: text('id').primaryKey(),
  cardId: text('card_id').notNull().references(() => cards.id),
  answer: text('answer').notNull(), // 'ok' or 'ng'
  answeredAt: integer('answered_at', { mode: 'timestamp' }).notNull(),
  nextReviewAt: integer('next_review_at', { mode: 'timestamp' }).notNull(),
});
Enter fullscreen mode Exit fullscreen mode

3. API Routes Implementation

// app/api/cards/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { cards } from '@/lib/db/schema';
import { z } from 'zod';

const createCardSchema = z.object({
  content: z.string().min(1),
  description: z.string(),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { content, description } = createCardSchema.parse(body);

    const card = await db.insert(cards).values({
      id: crypto.randomUUID(),
      content,
      description,
      createdAt: new Date(),
    }).returning();

    return NextResponse.json(card[0], { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to create card' },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Dockerfile

FROM node:20-alpine AS base

# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci

# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Production
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

# Create temporary directory (for SQLite file)
RUN mkdir -p /tmp

EXPOSE 8080

ENV PORT=8080

CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

5. Cloud Run Deployment

cloudbuild.yaml:

steps:
  - name: 'gcr.io/cloud-builders/docker'
    args: ['build', '-t', 'gcr.io/$PROJECT_ID/vocabulary-app', '.']
  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'gcr.io/$PROJECT_ID/vocabulary-app']
  - name: 'gcr.io/cloud-builders/gcloud'
    args:
      - 'run'
      - 'deploy'
      - 'vocabulary-app'
      - '--image'
      - 'gcr.io/$PROJECT_ID/vocabulary-app'
      - '--region'
      - 'asia-northeast1'
      - '--platform'
      - 'managed'
      - '--allow-unauthenticated'
      - '--set-env-vars'
      - 'GCS_BUCKET_NAME=vocabulary-app-db'
      - '--max-instances'
      - '1'
Enter fullscreen mode Exit fullscreen mode

Deployment Command:

# Deploy with Cloud Build
gcloud builds submit --config cloudbuild.yaml

# Or deploy directly
gcloud run deploy vocabulary-app \
  --source . \
  --region asia-northeast1 \
  --platform managed \
  --allow-unauthenticated \
  --set-env-vars GCS_BUCKET_NAME=vocabulary-app-db \
  --max-instances 1
Enter fullscreen mode Exit fullscreen mode

Frequently Asked Questions

Q: Is SQLite okay?

A: It's sufficient for small projects or personal development. Consider migrating to PostgreSQL (Cloud SQL) in the following cases:

  • Multi-user support is required
  • Large-scale data (several GB or more)
  • High traffic (100+ requests per second)
  • Complex queries or transactions

Q: What about data consistency with multiple instances?

A: Cloud Run may start multiple instances. Since SQLite has constraints on concurrent writes from multiple processes, we recommend the following measures:

  1. Set maximum instances to 1: --max-instances 1
  2. Migrate to Cloud SQL: When multi-user support is needed

Q: Will startup time be slower?

A: Downloading from Cloud Storage takes several seconds. The response time for the first request may increase, but it's usually within acceptable limits.

Q: What about backups?

A: The following methods are available:

  1. Periodic uploads: Upload to Cloud Storage every 5 minutes
  2. Cloud Storage versioning: Enable file version management
  3. Migrate to Cloud SQL: Use automatic backup features

Q: What about security?

A: We recommend the following measures:

  • Properly configure Cloud Storage bucket access control
  • Grant minimum permissions to Cloud Run service account
  • HTTPS required (Cloud Run handles this automatically)
  • Manage secrets with environment variables (Secret Manager)

Summary

The benefits of this configuration:

Cost Efficiency: Effectively $0 within free tier, approximately $0-5/month when exceeded

Simple: Single container architecture, no maintenance required

Scalable: Auto-scaling based on traffic (can scale down to 0)

Type Safe: Improved development efficiency with unified TypeScript

Practical: Suitable for personal development to small projects

Projects Suitable for This Configuration:

  • Personal development / Portfolio
  • Small-scale web applications
  • Prototype / MVP development
  • Learning projects

Future Extensions:

  • Multi-user support → Migrate to PostgreSQL (Cloud SQL)
  • High traffic → Adjust Cloud Run scaling settings
  • Global expansion → Cloud CDN + Multiple regions

Even beginners can achieve ultra-low-cost GCP deployment with this configuration. Especially when leveraging Cloud Run's generous free tier, you can operate for effectively $0. Give it a try!


Reference Links

Top comments (0)