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) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
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
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
Implementation Points
1. Cloud Storage Configuration
Bucket Creation:
- Create a Cloud Storage bucket in the GCP console
- Select a region (recommended: same region as Cloud Run)
- 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
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);
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
Important Considerations
-
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)
-
Increased Startup Time
- Downloading from Cloud Storage takes time (several seconds)
- May increase response time for the first request
-
Data Loss Risk
- If the process terminates abnormally, the latest data may not be uploaded
- Solution: Implement periodic backups (e.g., every 5 minutes)
-
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
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(),
});
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 }
);
}
}
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"]
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'
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
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:
-
Set maximum instances to 1:
--max-instances 1 - 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:
- Periodic uploads: Upload to Cloud Storage every 5 minutes
- Cloud Storage versioning: Enable file version management
- 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!
Top comments (0)