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](https://habrastorage.org/getpro/habr/upload_files/5f9/dd7/dd2/5f9dd7dd2bdabce18a1cf930f3e5dd44.png)
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-metrics
which collects metrics by requests and you can immediately display graphs for them according to the config from the repository
![Metrics architecture Metrics architecture](https://habrastorage.org/getpro/habr/upload_files/c5c/29c/521/c5c29c52196a37f1b2fe4d0900c76abe.png)
![Grafana Grafana](https://habrastorage.org/getpro/habr/upload_files/a92/353/f21/a92353f21fc4abc5395eb566c0d08c85.png)
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](https://habrastorage.org/getpro/habr/upload_files/ffa/f08/6b8/ffaf086b85c98e530f0f055f1bce5ba4.png)
![Loki Loki](https://habrastorage.org/getpro/habr/upload_files/d7d/7d5/7d3/d7d7d57d342dabdd31b28d341c24d56c.png)
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](https://habrastorage.org/getpro/habr/upload_files/f5f/a0f/b11/f5fa0fb119f879282f539c27196a24cb.png)
![Path 1 of the request in Jaeger Path 1 of the request in Jaeger](https://habrastorage.org/getpro/habr/upload_files/855/681/910/85568191036fa75bf731b80eba055d6e.png)
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](https://habrastorage.org/getpro/habr/upload_files/713/14e/47e/71314e47e95fe922fbd2d9b67fa8fb9a.png)
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](https://habrastorage.org/getpro/habr/upload_files/c70/852/62a/c7085262a2366abed479672643ed5538.png)
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