Building a Trading Journal with FastAPI, PostgreSQL, and Docker Compose
A small multi-tier app, containerized from scratch for my Cloud Services midterm at BINUS University.
Why I built this
I trade gold (XAUUSD) with small lots, and for a long time my trade history lived in scattered notes and screenshots. I could not answer simple questions like "what is my win rate?" or "is my total moving up or down?" So I decided to build a small journal where I record each trade and instantly see the numbers.
There was a second reason. My Cloud Services course asked us to build a multi-tier application and run it entirely in containers on localhost. The grade is not about how many features the app has, but about how well it is containerized. A trading journal is small enough to finish in a day and real enough to be useful, which made it a good fit for practicing cloud-native fundamentals. It only stores dummy trade data (pair, lot, entry, exit), so no personal data is involved.

Architecture

The stack has three containers on a user-defined network called appnet:
• app: FastAPI served by Uvicorn on port 8000. It exposes GET /health, GET /trades, and POST /trades, and also serves the web page (HTML, CSS, and JavaScript).
• db: PostgreSQL 16.4 with a named volume, pgdata.
• adminer: a database UI that only starts under the tools profile.
A request goes from the browser to localhost:8000, the app connects to the database by its service name db, and the data is stored in the volume. I chose containers over virtual machines because containers share the host kernel (isolated with namespaces and limited with cgroups), so they are smaller and start in seconds instead of minutes.

The Dockerfile
I used a multi-stage build:
FROM python:3.12.7-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12.7-slim AS runtime
WORKDIR /app
ENV PYTHONUNBUFFERED=1
COPY --from=builder /install /usr/local
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
A few decisions worth explaining:
• Pinned base image tag (3.12.7-slim) instead of latest, so builds are reproducible.
• Dependencies are copied before the source code. As long as requirements.txt does not change, Docker reuses the cached install layer, so a code change rebuilds in seconds.
• Multi-stage build. The runtime stage starts from a clean image and copies only the installed packages and my code, leaving build leftovers behind. The final image is [IMAGE SIZE FROM docker images].
• COPY instead of ADD. ADD can download URLs and auto-extract archives, which I did not need, so I used the more predictable COPY.
• Exec-form CMD. Uvicorn runs as the main process and receives stop signals correctly.

Docker Compose
The interesting part is how the services wait for each other:
db:
image: postgres:16.4
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 10

app:
build: .
env_file: .env
depends_on:
db:
condition: service_healthy
The database has a healthcheck, and the app starts only after it reports healthy. This avoids the classic startup crash where the app tries to connect before Postgres is ready. Configuration comes from a .env file that is never committed; the repository only contains .env.example. Adminer sits behind the tools profile, so it does not run unless I ask for it with docker compose --profile tools up -d.
Running it locally
git clone https://github.com/httpsvincent/trading_jurnal.git
cd trading_jurnal
cp .env.example .env
docker compose up -d --build
Then open http://localhost:8000/. The docker compose ps output shows the app running and the database healthy:

The API returns proper status codes: 200 for GET /health and GET /trades, 201 for a successful POST /trades, and 422 for invalid input.

Testing persistence
I added a few trades, ran docker compose down, then docker compose up -d, and the trades were still there. That is because down removes containers and networks but keeps named volumes. docker compose down -v also removes the volume, and the data is gone.
[INSERT SCREENSHOT: before and after docker compose down]
What I learned
• A healthcheck combined with service_healthy solves a very common startup race between an app and its database.
• Copying requirements.txt before the source code makes rebuilds dramatically faster.
• Keeping secrets in .env and committing only .env.example is a simple habit that keeps a public repository safe.
• Changing database credentials in .env does not change an existing Postgres volume, so you may need docker compose down -v to recreate it.
• [ADD ONE PERSONAL LESSON, e.g. a Git or Docker error you hit and how you fixed it]
Links
• GitHub: https://github.com/httpsvincent/trading_jurnal
• Demo video: [YOUTUBE LINK]