Firebase VS yours

Many startups start with Firebase, then, out of reluctance to pay Google, they switch to their own servers. Essentially, creating their own Firebase as a platform or wanting to repeat developer experience from Firebase – that's what we're going to talk about

With nuances about the technology stack, in particular the choice of programming language, and we will evaluate the efforts to escape from Firebase and vercel. Let's take a look at the example of my pet project – Github. Video demo below:


About the client

Thanks to Firebase rules, interaction with the database can be safely left on the client. In our case, we do not allow ourselves to do this and the client relies on the server regarding authentication, database, analytics and deployment

With Firebase, not everything is so rosy, if the rules are configured incorrectly, you can extract the database with this script, launched from the browser console under an authorized user. The config is easily found in the site code
const script = document.createElement('script');
script.type="module";
script.textContent = `
  import { initializeApp } from "<https://www.gstatic.com/firebasejs/10.3.1/firebase-app.js>";
  import { getAuth }       from '<https://www.gstatic.com/firebasejs/10.3.1/firebase-auth.js>'
  import { getAnalytics }  from "<https://www.gstatic.com/firebasejs/10.3.1/firebase-analytics.js>";
  import { getFirestore, collection, getDocs, addDoc }  from '<https://www.gstatic.com/firebasejs/10.3.1/firebase-firestore.js>'
// TODO: search for it in source code
const firebaseConfig = {
apiKey: "<>",
authDomain: "<>.firebaseapp.com",
projectId: "<>",
storageBucket: "<>.appspot.com",
messagingSenderId: "<>",
appId: "<>",
measurementId: "G-<>"
};
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
window.app = app
window.analytics = analytics
window.db = getFirestore(app)
window.collection = collection
window.getDocs = getDocs
window.addDoc = addDoc
window.auth = getAuth(app)
alert("Houston, we have a problem!")
`;
document.body.appendChild(script);

A nice practice is to put work with Firebase in a separate file, the functions are replaced with work with the API and everything remains unchanged. The example uses a tandem of Axios and tanstack

Deploy with docker

First, we compile Vite using commands in package.json, and then expose the compiled application via nginx

# Build stage
FROM node:21.6.2-alpine as build
WORKDIR /client
COPY package.json yarn.lock ./
RUN yarn config set registry <https://registry.yarnpkg.com> && \\
    yarn install
COPY . .
RUN yarn build

# Serve stage
FROM nginx:alpine
COPY --from=build /client/build /usr/share/nginx/html
COPY --from=build /client/nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

About the server

For practice, I chose Golang, but any popular language has libraries for working with the database and processing requests. The differences between languages ​​will become apparent later.

Authentication

Everything is like for people, you are given a choice of registration through providers or by email. I used JWT tokens and Google provider and there are already libraries for each language

For Google authentication (both login and registration) 2 handles are defined:

  • /api/v1/google/login — the “Login with Google” button leads here

  • /api/v1/google/callback — if successful, a redirect from Google comes here to put the user in the database and generate a JWT token for him. This URL is registered in Google Cloud (localhost works, but local domains don't)

The user has a Providers field in the database and any logic for processing these providers comes from it.

What is typical for JWT tokens is that they cannot be cancelled. For the “log out” button, tokens are blacklisted by connecting Redis and specifying the key lifetime before the token expires

I decided to store JWT tokens in httpOnly cookies, I chose this path based on the alternatives:

  • because of the redirect from google I can't specify the token in the response header, react without SSR won't be able to read it

  • I didn't want to leave the token in the URL, because then I'd have to get it from the frontend

CORS

I allow cookies to work with cookies Access-Control-Allow-Credentials and I put Access-Control-Allow-Origin where I place my domains, local host and necessary infrastructure

	corsCfg := cors.DefaultConfig()
	corsCfg.AllowOrigins = []string{
		cfg.FrontendUrl,
		"http://prometheus:9090",
		"https://chemnitz-map.local",
		"https://api.chemnitz-map.local",
		"http://localhost:8080"}
	corsCfg.AllowCredentials = true
	corsCfg.AddExposeHeaders(telemetry.TraceHeader)
	corsCfg.AddAllowHeaders(jwt.AuthorizationHeader)
	r.Use(cors.New(corsCfg))

Environment variables

The problem with working with env: variables cannot be stored in the code base. Alone, you can store everything locally on your computer, but when you work together, this creates problems for each other with throwing variables around when updating them.

I solved this with a script that pulls variables from Gitlab CI/CD variables, but this tied me to Gitlab, but ideally Vault is connected here

Unit tests

You can't hide from them, whether with Firebase or with your own server. Their job is to provide confidence and do less manual testing.

I covered my business logic with Unit tests and felt the difference: at a late stage the project changed a field in the user entity – a minor change and nevertheless this entity has already been encountered in the code 27 times, the field itself is encrypted for the database and the database works with the DBO user entity, in requests it is parsed into JSON and back and to check the change with manual testing I need to poke each request a couple of times with different parameters

Swagger Requests Documentation

Swagger documentation, every request can be sent

Swagger documentation, every request can be sent

Swagger in Golang is inconvenient – instructions to swagger are written in comments to the code:

// GetUser godoc
//
//	@Summary		Retrieves a user by its ID
//	@Description	Retrieves a user from the MongoDB database by its ID.
//	@Tags			users
//	@Produce		json
//	@Security		BearerAuth
//	@Param			id	path		string						true	"ID of the user to retrieve"
//	@Success		200	{object}	dto.GetUserResponse			"Successful response"
//	@Failure		401	{object}	dto.UnauthorizedResponse	"Unauthorized"
//	@Failure		404	{object}	lib.ErrorResponse			"User not found"
//	@Failure		500	{object}	lib.ErrorResponse			"Internal server error"
//	@Router			/api/v1/user/{id} [get]
func (s *userService) GetUser(c *gin.Context){...}

Unlike .Net or Java, where swagger is configured via annotations: [SwaggerResponse(200, сообщение, тип)]

Moreover, generation in Golang does not happen automatically out of the box, so we call the swagger config build for each change. Life is made easier by setting up the IDE to call the generation script before building the application

#!/usr/bin/env sh
export PATH=$(go env GOPATH)/bin:$PATH

swag fmt && swag init -g ./cmd/main.go -o ./docs

Accordingly, it is more difficult to support swagger in Golang, and there are no alternatives with the same characteristics: request collections such as Postman, Insomnia or Hoppscotch are inferior to Swagger, because requests for them are created manually

And on top of that, using the swagger configuration (swagger.json), you can generate a Typescript file with all the requests via a command specifying the desired one generator from the list

swagger-codegen generate -i ./docs/swagger.json -l **typescript-fetch** -o ./docs/swagger-codegen-ts-api

Docker

Like the client, the server is assembled in 2 stages:

# build stage
FROM golang:1.22.3-alpine3.19 AS builder
WORKDIR /app
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . .
RUN GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o main ./cmd/main.go

# run stage
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/main .
COPY --from=builder /app/app.yml .
COPY --from=builder /app/resources/datasets/ ./resources/datasets/
EXPOSE 8080
CMD ["/app/main"]

For Go, don't forget to specify the operating system for the build and go mod download for the cache


About monitoring

We want to repeat the experience with Firebase, so we are setting up systems for logs and metrics.

Prometheus & Grafana Metrics

Thanks to metrics, we understand the load on the server. There is a library for Go penglongli/gin-metricswhich collects metrics by requests and you can immediately display graphs for them according to the config from the repository

Metrics architecture

Metrics architecture

Grafana

Grafana

Logs in Loki

It is considered good practice to take logs directly from docker containers, and not with an http logger, but I did not go for it

One way or another, we write logs in a structured JSON format so that a third-party system can chew and filter it. Usually we use a custom logger here, I used Zap

Architecture of logs

Architecture of logs

Loki

Loki

openTelemetry and tracing via Jaeger

Each request is accompanied by an x-trace-id header, which allows you to view the entire path of the request in the system (relevant for microservices)

Tracing architecture

Tracing architecture

Path 1 of the request in Jaeger

Path 1 of the request in Jaeger

The choice of programming language plays an important role; popular enterprise languages ​​(Java, C#) support the openTelemetry standard well: Language APIs & SDKs. Golang is younger and currently does not fully support log collection (Beta). Tracing is less convenient, it is more difficult to see the request path in the system

Pyroscope

You can run load or stress tests, or you can connect Pyroscope and watch the load, memory and real-time flows. Although, of course, Pyroscope itself eats up a percentage of performance

Pyroscope and memory allocation in the application

Pyroscope and memory allocation in the application

In the context of optimization, when choosing a programming language, we choose its potential, because there is no point in comparing the speed ceiling of Go, Rust, Java, C#, JS without optimization. But optimization requires man-hours, and from a business point of view, it may be more relevant to look at out-of-the-box performance, availability of specialists, and language development.

Sentry

Server errors often cause losses, so there is a system that collects the full path and context of the error both from the frontend, allowing you to see what the user clicked, and from the backend

Sentry with errors

Sentry with errors

Deploying Monitoring via Docker Compose

This is the easiest way to put it all together, without forgetting to configure healthcheck, volume and security configs for all these services.

server/docker-compose.yml
services:
  # ----------------------------------- APPS
  chemnitz-map-server:
    build: .
    develop:
      watch:
        - action: rebuild
          path: .
    env_file:
      - .env.production
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "<http://localhost:80/api/v1/healthcheck>"]
      interval: 15s
      timeout: 3s
      start_period: 1s
      retries: 3
    ports:
      - "8080:8080"
    networks:
      - dwt_network
    depends_on:
      mongo:
        condition: service_healthy
      loki:
        condition: service_started
----------------------------------- DATABASES
mongo:
image: mongo
healthcheck:
test: mongosh --eval 'db.runCommand("ping").ok' --quiet
interval: 15s
retries: 3
start_period: 15s
ports:
- 27017:27017
volumes:
- mongodb-data:/data/db
- ./resources/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js
networks:
- dwt_network
env_file: .env.production
command: ["--auth"]
----------------------------------- INFRA
[MONITORING] Prometheus
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./resources/prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- dwt_network
[MONITORING] Grafana
grafana:
image: grafana/grafana
ports:
- "3030:3000"
networks:
- dwt_network
env_file: .env.production
environment:
- GF_FEATURE_TOGGLES_ENABLE=flameGraph
volumes:
- ./resources/grafana.yml:/etc/grafana/provisioning/datasources/datasources.yaml
- ./resources/grafana-provisioning:/etc/grafana/provisioning
- grafana:/var/lib/grafana
- ./resources/grafana-dashboards:/var/lib/grafana/dashboards
[profiling] - Pyroscope
pyroscope:
image: pyroscope/pyroscope:latest
deploy:
restart_policy:
condition: on-failure
ports:
- "4040:4040"
networks:
- dwt_network
environment:
- PYROSCOPE_STORAGE_PATH=/var/lib/pyroscope
command:
- "server"
[TRACING] Jaeger
jaeger:
image: jaegertracing/all-in-one:latest
networks:
- dwt_network
env_file: .env.production
ports:
- "16686:16686"
- "14269:14269"
- "${JAEGER_PORT:-14268}:14268"
[LOGGING] loki
loki:
image: grafana/loki:latest
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
volumes:
- ./resources/loki-config.yaml:/etc/loki/local-config.yaml
networks:
- dwt_network
----------------------------------- OTHER
networks:
dwt_network:
driver: bridge
Persistent data stores
volumes:
mongodb-data:
chemnitz-map-server:
grafana:

And it will work, but only within one machine.


About deployment on K8S

If 1 machine can handle your workload, I assume you won't go much beyond the free plan in Firebase, not so much that there is an economic incentive to pay for the transfer of the entire system and scale yourself.

If we take the average RPS of 100 requests/second, which can be easily processed by 1 server, then Firebase will charge $100 per month just for the features + fee for DB and storage + vercel hosting

Docker Compose is no longer enough to scale on your servers + the entire monitoring infrastructure only complicates the move to several machines

k8s is codebase agnostic, it takes containers from the registry and works with them. Usually you create your own private registry, but I used the public docker hub

For each service, config and secrets we create our own deployment and service manifests, connect the database using PersistentVolume and PersistentVolumeClaim, then write ingress, connect the certificate from Let's Encrypt and voila!

Then, if it is necessary to administer several machines, terraform or ansible is connected.

We also have options to configure blue/green deployment, stage/prod via helm, connect nginx mesh, which is more difficult to do with Firebase (if not impossible), but in Firebase it is easier to direct the user to the geographically closest server and protect against DDOS attacks


Almost every one of the topics mentioned rests on infrastructure and the ability to work with it, so questions remain

  • Tutorials rarely cover topics of deployment, infrastructure, optimization and scaling. Can Junior developers handle this?

  • How much will all this work cost?

  • How much do servers cost?

  • What is the cost of a mistake?

  • Is it possible to just cut features and not cut costs?

However, no matter how you look at it, neither Firebase nor Vercel are intended for Highload – this is confirmed by stories of bills for hundreds of thousands of dollars for suddenly taking off applications, and the big question remains whether this is resolved by the pricing policy

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *