<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vincentius Christian Hermawan</title>
    <description>The latest articles on DEV Community by Vincentius Christian Hermawan (@httpsvincent).</description>
    <link>https://dev.to/httpsvincent</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4236333%2Fef0f0ef4-5a51-4614-80a0-62e413230e76.png</url>
      <title>DEV Community: Vincentius Christian Hermawan</title>
      <link>https://dev.to/httpsvincent</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/httpsvincent"/>
    <language>en</language>
    <item>
      <title>Article - Trading Journal with FastAPI, PostgreSQL, and Docker Compose</title>
      <dc:creator>Vincentius Christian Hermawan</dc:creator>
      <pubDate>Tue, 10 Nov 2026 05:37:18 +0000</pubDate>
      <link>https://dev.to/httpsvincent/article-trading-journal-with-fastapi-postgresql-and-docker-compose-4c6</link>
      <guid>https://dev.to/httpsvincent/article-trading-journal-with-fastapi-postgresql-and-docker-compose-4c6</guid>
      <description>&lt;p&gt;Building a Trading Journal with FastAPI, PostgreSQL, and Docker Compose&lt;br&gt;
A small multi-tier app, containerized from scratch for my Cloud Services midterm at BINUS University.&lt;br&gt;
Why I built this&lt;br&gt;
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.&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwaqx4cg5u99k24ou0hjp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwaqx4cg5u99k24ou0hjp.png" alt=" " width="799" height="548"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Architecture&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizxx1dl2m80svf6ybgdl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizxx1dl2m80svf6ybgdl.png" alt=" " width="800" height="503"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stack has three containers on a user-defined network called appnet:&lt;br&gt;
• 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).&lt;br&gt;
• db: PostgreSQL 16.4 with a named volume, pgdata.&lt;br&gt;
• adminer: a database UI that only starts under the tools profile.&lt;br&gt;
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.&lt;/p&gt;

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

&lt;p&gt;FROM python:3.12.7-slim AS runtime&lt;br&gt;
WORKDIR /app&lt;br&gt;
ENV PYTHONUNBUFFERED=1&lt;br&gt;
COPY --from=builder /install /usr/local&lt;br&gt;
COPY app/ ./app/&lt;br&gt;
EXPOSE 8000&lt;br&gt;
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]&lt;br&gt;
A few decisions worth explaining:&lt;br&gt;
• Pinned base image tag (3.12.7-slim) instead of latest, so builds are reproducible.&lt;br&gt;
• 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.&lt;br&gt;
• 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].&lt;br&gt;
• COPY instead of ADD. ADD can download URLs and auto-extract archives, which I did not need, so I used the more predictable COPY.&lt;br&gt;
• Exec-form CMD. Uvicorn runs as the main process and receives stop signals correctly.&lt;/p&gt;

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

&lt;p&gt;app:&lt;br&gt;
  build: .&lt;br&gt;
  env_file: .env&lt;br&gt;
  depends_on:&lt;br&gt;
    db:&lt;br&gt;
      condition: service_healthy&lt;br&gt;
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.&lt;br&gt;
Running it locally&lt;br&gt;
git clone &lt;a href="https://github.com/httpsvincent/trading_jurnal.git" rel="noopener noreferrer"&gt;https://github.com/httpsvincent/trading_jurnal.git&lt;/a&gt;&lt;br&gt;
cd trading_jurnal&lt;br&gt;
cp .env.example .env&lt;br&gt;
docker compose up -d --build&lt;br&gt;
Then open &lt;a href="http://localhost:8000/" rel="noopener noreferrer"&gt;http://localhost:8000/&lt;/a&gt;. The docker compose ps output shows the app running and the database healthy:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7ag6mem62t8xokq1fht.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7ag6mem62t8xokq1fht.png" alt=" " width="799" height="282"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flk72cu125dbxmh59j0lv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flk72cu125dbxmh59j0lv.png" alt=" " width="800" height="311"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flt3ontq42i2qqf9yc3i5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flt3ontq42i2qqf9yc3i5.png" alt=" " width="800" height="67"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Testing persistence&lt;br&gt;
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.&lt;br&gt;
[INSERT SCREENSHOT: before and after docker compose down]&lt;br&gt;
What I learned&lt;br&gt;
• A healthcheck combined with service_healthy solves a very common startup race between an app and its database.&lt;br&gt;
• Copying requirements.txt before the source code makes rebuilds dramatically faster.&lt;br&gt;
• Keeping secrets in .env and committing only .env.example is a simple habit that keeps a public repository safe.&lt;br&gt;
• Changing database credentials in .env does not change an existing Postgres volume, so you may need docker compose down -v to recreate it.&lt;br&gt;
• [ADD ONE PERSONAL LESSON, e.g. a Git or Docker error you hit and how you fixed it]&lt;br&gt;
Links&lt;br&gt;
• GitHub: &lt;a href="https://github.com/httpsvincent/trading_jurnal" rel="noopener noreferrer"&gt;https://github.com/httpsvincent/trading_jurnal&lt;/a&gt;&lt;br&gt;
• Demo video: [YOUTUBE LINK]&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>database</category>
      <category>docker</category>
      <category>fastapi</category>
    </item>
  </channel>
</rss>
