Full-Stack Architecture & Deployment
A personal full-stack project covering application architecture, deployment, infrastructure, asynchronous processing, backups, and recovery.
Published:
Updated:
I built Funky Recipes primarily during 2022–2024 as a personal full-stack project. It had its own domain, a React frontend deployed through Firebase Hosting, and a Node.js backend running on DigitalOcean. The project gave me hands-on ownership across application development, backend architecture, deployment, and infrastructure.
The project covered React, Apollo Client, GraphQL, backend architecture, ArangoDB, Redis Streams, Docker, Nginx, GitHub Actions CI/CD, Firebase Hosting, DigitalOcean, authentication, backups, and recovery.
System Architecture
The frontend was a React application using Apollo Client and deployed through Firebase Hosting. The Node.js backend ran on DigitalOcean behind Nginx as a Docker Compose stack with Redis and ArangoDB.
API requests went from Apollo Client to the configured API domain, through Nginx to the backend container. The backend used Redis Streams for asynchronous message processing and ArangoDB for persistence.
A closer look at the application boundaries
The more detailed view below traces the connections in the frontend and backend code and configuration. It shows how the pieces fit together; it is not a record of every version deployed during the life of the project.
Public recipe lists and detail pages read bundled JSON through FRApiProvider.
The profile fetched recipes through GraphQL, and the creation form submitted a
mutation. These paths coexisted: browsing public recipes did not require a
working database connection, and creating a recipe did not automatically update
the bundled public content.
Open detailed diagram in full size →
Firebase handled frontend delivery, including a fallback to index.html for
browser routing. API traffic crossed a separate boundary through Nginx. Keeping
those paths separate helped me reason about where a problem belonged: frontend
delivery, API access, application execution, or persistence.
Backend Application Flow
The backend used commands, use cases, dependency injection, repositories, and application events. Recipe creation is one example.
A GraphQL mutation created a CreateRecipeCommand and sent it through ICommandBus. A command listener resolved the corresponding use case, which called the repository and emitted a RecipeCreated event. As the detailed flow below shows, those calls did not guarantee that the write finished before the event was emitted.
@ListenCommand(CreateRecipeCommand)
@Service()
export class CreateRecipeUseCase
implements IUseCase<CreateRecipeCommand, void>
{
constructor(
@Inject("IEventBus") private readonly eventBus: IEventBus,
@Inject("ILogger") private readonly logger: ILogger,
@Inject("RecipeRepository") private recipeRepository: RecipeRepository
) {}
public async execute(data: CreateRecipeCommand) {
this.recipeRepository.create({ ...data.payload });
await this.eventBus.send(new RecipeCreated({ ...data.payload }));
}
}Commands and events had separate roles: commands requested an action, while events announced something that had happened.
I would describe the architecture as ports-and-adapters-inspired rather than strictly hexagonal. Dependency injection and interfaces created useful boundaries, although some use cases still depended directly on infrastructure implementations.
Commands, events, and completion
The resolver checked for an authenticated user and attached that user’s ID after
the submitted input, so the payload could not override the recipe author.
CreateRecipeCommand validated the input before the command bus serialized its
type, timestamp, and payload into a Redis stream.
On the receiving side, the stream consumer dispatched the command through a
shared Node EventEmitter. The ListenCommand decorator registered the handler
by command class name and resolved its use case through TypeDI.
Open detailed diagram in full size →
The event bus used that same emitter, but did not persist events or publish them
to Redis. One concrete event path handled ApplicationStartedEvent and created
collections and a graph in SETUP mode. The implementation shown here had no
downstream listener for RecipeCreated.
Other operations took a shorter route. Login, registration, token refresh, and recipe reads invoked use cases directly. Even where an operation constructed a command object, that did not necessarily mean it went through the queue.
Looking back, the most useful lesson is about completion. The resolver waited
for command publication, the consumer acknowledged after emitting, and the
listener invoked the use case without awaiting it. The use case also called
repository.create without awaiting the write before emitting RecipeCreated.
A successful mutation response therefore meant less than a confirmed database
write. Today I would make those milestones explicit before relying on the
response or event to trigger further work.
Persistence with ArangoDB
ArangoDB was used for both document storage and graph relationships. Recipe persistence created a recipe document and linked it to its author.
const newRecipe = await (await this.client)
.collection<Partial<IRecipe>>(this.collectionName)
.save({
...data,
createdAt: new Date(),
slug: slugify(data.title),
userId,
});
if (userId) {
await this.link(newRecipe._id, userId, properties.meta || undefined);
}Working with ArangoDB gave me practical experience with document and graph data modelling as well as database setup and operation.
Deployment and operational ownership
The backend CI/CD pipeline tested the application with Redis and ArangoDB, built and pushed a Docker image, connected to the server over SSH, ran a database backup, and deployed the new services with Docker Compose.
The deployed stack included the backend, Redis, and ArangoDB:
services:
database:
image: arangodb/arangodb:3.7.18
volumes:
- /mnt/block-volume:/var/lib/arangodb3
redis:
image: redis:6.2.6-buster
backend:
image: docker.pkg.github.com/tkhiienlok/funkyrecipes/backend:${TAG}
ports:
- 8080:80
env_file:
- ./.envNginx exposed the API and proxied requests to the backend container:
server_name api.funkyrecipes.dev;
location / {
proxy_pass http://127.0.0.1:8080;
include nginxconfig.io/proxy.conf;
}This part of the project gave me direct experience with server configuration, containers, persistent storage, environment configuration, and deployment automation.
How the release paths connected
The two repositories had separate delivery workflows. Frontend pushes to main
built the application for Firebase’s live channel; pull requests from the same
repository received preview deployments. Backend releases had a different chain:
tests with Redis and ArangoDB, a dependent Docker image build, then deployment
over SSH.
Open detailed diagram in full size →
The backend image build generated GraphQL types, compiled TypeScript, and copied
the schema into the output. The container then ran the compiled application with
pm2-runtime. Deployment transferred the environment and Compose configuration,
ran the backup script, and pulled and started the services.
Some operational work remained manual. My Nginx instructions covered preserving
the previous configuration, obtaining certificates, checking with nginx -t,
and reloading the service. The diagram brings those tasks together with the
release workflow, including the storage mount that both the database and backup
script depended on.
Backups and recovery
The deployment workflow created a database backup before replacing the running backend stack.
The backup script ran arangodump inside the database container, stored the dump on the mounted block volume, compressed it, and copied it to a separate backup repository.
The script checked dump, compression, and copy failures, but did not explicitly check whether the Git push succeeded before printing completion. It also expected an existing database container, so this was a backup step for updating a running stack, not a complete first-deployment procedure.
I documented how to check archive directories, diagnose a missing volume mount, and retrieve backup archives. That covered part of recovery, but not a complete, verified database restore. Today I would check remote backup delivery and add automated restore testing so that recovery is exercised as well as documented.
Authentication
The implemented authentication flow included password login, access and refresh tokens, bearer-token handling in the GraphQL context, and frontend session handling.
This gave me practical experience with authentication across browser and API boundaries, including token storage, validation, and authorization context.
What I would change today
I would make command completion semantics clearer so that persistence, acknowledgement, and emitted events have explicit ordering.
I would also make the ports-and-adapters boundary stricter by keeping repository interfaces on the application side and infrastructure implementations behind them.
For operations, I would add automated restore tests and use managed infrastructure where owning the underlying server no longer provides enough value.
What this project gave me
The project gave me experience beyond application code: GraphQL, backend architecture, document and graph data modelling, Redis Streams, Docker, Nginx, GitHub Actions, Firebase Hosting, DigitalOcean, authentication, backups, recovery, and production operations.
More importantly, I owned these concerns as parts of one working system rather than as isolated technologies. Operating them together gave me a better understanding of the boundaries between application architecture and infrastructure, what managed platforms abstract away, and when owning infrastructure is useful.