{"id":186,"date":"2026-10-29T12:00:00","date_gmt":"2026-10-29T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=186"},"modified":"2026-10-30T07:14:25","modified_gmt":"2026-10-30T07:14:25","slug":"building-a-job-board-api-part-1-the-foundation","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=186","title":{"rendered":"Building a Job Board API, Part 1: The Foundation"},"content":{"rendered":"\n<h3 class=\"wp-block-heading\">Why I am building a &#8220;boring&#8221; app on purpose<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In this series, I want to share one of my projects with you from the very beginning. Not only the finished version with a nice README, but every step: the setup, the decisions, the mistakes and the fixes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A job board is not an exciting idea. Companies post jobs. Candidates apply. That&#8217;s it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I picked it on purpose.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I am a junior DevOps and Cloud engineer, and I am looking for my first role in SRE, DevOps or Cloud. That is why I started building real projects and sharing them here, step by step. I want to show that I can run real software in production: on Kubernetes, on AWS, on Azure, with monitoring, CI\/CD and secrets done properly. But to do that, I first need an application to deploy. A &#8220;Hello World&#8221; container is not enough. It has no database, no users, no background jobs, and nothing can go wrong with it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So I am building my own application: a Job Board REST API in Django. It is simple enough that I can understand every line. But it is real enough to have the problems real apps have: a database, two kinds of users, login tokens, file uploads, a queue and a background worker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is Part 1 of a 4-part series about Phase 1 of the project. In this part, I will not write a single API endpoint. Instead, I will build the foundation: the project setup, the config, the Dockerfile and Docker Compose. These are the boring parts. They are also the parts that decide if the app will be easy or painful to run later.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The big picture<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When the project is finished, the API will talk to four other services: PostgreSQL for data, Redis for caching job searches, S3 (or Azure Blob Storage) for resume files, and a queue (SQS or Azure Service Bus) for background jobs. A separate worker process reads the queue and sends emails when an application status changes.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"722\" height=\"400\" src=\"http:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/job-board-api-architecture-diagram.png\" alt=\"\" class=\"wp-image-189\" srcset=\"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/job-board-api-architecture-diagram.png 722w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/job-board-api-architecture-diagram-300x166.png 300w\" sizes=\"auto, (max-width: 722px) 100vw, 722px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">In Phase 1, only the API and PostgreSQL exist. Every other box arrives in a later phase.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The project has six phases:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Core app: models, JWT login, job and application endpoints. Done when a company can post a job and a candidate can apply, locally.<\/li>\n\n\n\n<li>Business logic: permission rules, skill match score, resume upload to S3, filters. Done when wrong user types get 403 and match scores are correct.<\/li>\n\n\n\n<li>Background worker: Redis cache, a queue, and a worker that sends emails. Done when a status change queues a job and the worker processes it.<\/li>\n\n\n\n<li>Operations-ready: health checks, Prometheus metrics, JSON logs, a multi-stage Dockerfile. Done when the container runs with only env vars and exposes health and metrics.<\/li>\n\n\n\n<li>Tests and docs: pytest with 80%+ coverage and OpenAPI docs. Done when one command runs all tests green.<\/li>\n\n\n\n<li>Azure portability: one CLOUD_PROVIDER variable switches between AWS and Azure. Done when the same image runs on AWS or Azure by changing env vars only.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">This series is about Phase 1. The data model is small: a custom User with a role, a Company, a CandidateProfile, a Job and an Application.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Phase 1 also has no cloud resources. Everything runs locally on thor. Nothing costs money yet.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1: Starting the project with uv<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the first Python project in my portfolio, so I chose the tool carefully. I used uv. It is a fast Python package and project manager. It replaces pip, venv and pip-tools with one tool.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>git init\nuv init --bare --name job-board-api --python 3.12\nuv python pin 3.12<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>uv init &#8211;bare creates only one file: pyproject.toml. Without &#8211;bare, uv also creates a sample main.py and a README that I don&#8217;t need.<\/li>\n\n\n\n<li>&#8211;name job-board-api sets the project name. Without it, uv uses the folder name, which has my portfolio number in it (00-job-board-api).<\/li>\n\n\n\n<li>uv python pin 3.12 writes a small file called .python-version.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That last line matters more than it looks. My server has Python 3.14 installed. My Docker image will use Python 3.12. If I only wrote requires-python &gt;= 3.12, uv could happily pick 3.14 on my machine. Then my laptop and my container would run different Python versions, and one day something would work in one place and break in the other.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The pin solves this. uv downloads its own copy of 3.12 and does not touch the system Python. I checked it with uv run python &#8211;version, and it printed Python 3.12.15.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then I added the packages:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>uv add \"django&gt;=5.2,&lt;5.3\" djangorestframework \"psycopg&#91;binary]\"<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>django>=5.2,&lt;5.3 keeps Django on the 5.2 line. 5.2 is an LTS (long-term support) release. The upper limit stops a future upgrade from jumping to Django 6 by surprise.<\/li>\n\n\n\n<li>djangorestframework (DRF) is the toolkit for building REST APIs on top of Django.<\/li>\n\n\n\n<li>psycopg[binary] is the PostgreSQL driver. The [binary] part installs pre-built code, so I don&#8217;t need a C compiler on my server or in my image.<\/li>\n\n\n\n<li>The quotes are important. Without them, bash reads &lt; and > as file redirection and tries to create a file called 5.3.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">uv add also writes a lockfile called uv.lock. A lockfile records the exact version of every package, including the packages that my packages depend on. Because of this file, my server, my Docker image and (later) my CI pipeline install exactly the same versions. I commit uv.lock and .python-version to git. I never commit the .venv\/ folder, because uv can rebuild it from the lockfile at any time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, I created the Django project:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>uv run django-admin startproject config .<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The dot at the end means &#8220;create it in this folder&#8221;. Without it, Django creates an extra folder level (config\/config\/). I named the package config because it only holds project-wide settings. The real app code will live in separate apps like jobs\/ and worker\/.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2: Every setting comes from environment variables<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Django&#8217;s startproject gives you a settings.py with values written directly in the file: a secret key, DEBUG = True, and a SQLite database. That is fine for a tutorial. It is not fine for an app that will run on my laptop, in Docker Compose, on EKS and on AKS.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I follow the 12-factor rule: the code is the same everywhere, and only the environment variables change. On my server they come from a .env file. In Kubernetes they will come from Secrets and ConfigMaps.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A small function that fails loudly<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>def env(name, default=None):\n    \"\"\"Read a setting from the environment. Fail loudly if a required one is missing.\"\"\"\n    value = os.environ.get(name, default)\n    if value is None:\n        raise ImproperlyConfigured(f\"Missing required environment variable: {name}\")\n    return value<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The idea is simple:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>If I call env(&#8220;DJANGO_SECRET_KEY&#8221;) with no default, the variable is required. If it is missing, the app crashes at startup with a clear message that names the variable.<\/li>\n\n\n\n<li>If I call env(&#8220;POSTGRES_PORT&#8221;, &#8220;5432&#8221;) with a default, the variable is optional.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Why crash on purpose? Because the other option is worse. If the secret key silently became None, Django would fail later with a confusing error, far away from the real cause. A crash at startup that says &#8220;Missing required environment variable: DJANGO_SECRET_KEY&#8221; takes ten seconds to fix. In Kubernetes, it also shows up immediately in the pod logs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I tested this on purpose. env -u runs one command with one variable removed:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>env -u DJANGO_SECRET_KEY uv run python manage.py check\n# ImproperlyConfigured: Missing required environment variable: DJANGO_SECRET_KEY<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is a negative test: a test that proves something fails the right way.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The DEBUG trap<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>DEBUG = env(\"DJANGO_DEBUG\", \"False\").lower() in (\"1\", \"true\", \"yes\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Environment variables are always strings. And in Python, any non-empty string is true. So bool(&#8220;False&#8221;) is True. If I wrote DEBUG = bool(env(&#8220;DJANGO_DEBUG&#8221;)), debug mode would be on even when I set it to &#8220;False&#8221;.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That is dangerous. Debug mode shows full error pages with stack traces and settings to anyone who causes an error. So I compare the text instead. The default is &#8220;False&#8221;, which means: if someone forgets this variable in production, debug stays off. The safe option is the default.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">ALLOWED_HOSTS from a comma-separated list<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>ALLOWED_HOSTS = &#91;\n    host.strip()\n    for host in env(\"DJANGO_ALLOWED_HOSTS\", \"localhost,127.0.0.1\").split(\",\")\n    if host.strip()\n]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">ALLOWED_HOSTS is the list of hostnames Django will answer. Any other Host header gets a 400 Bad Request. This line is a list comprehension: it splits the string by commas, removes spaces around each item, and drops empty items (so a trailing comma does not add an empty host).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The database settings<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>DATABASES = {\n    \"default\": {\n        \"ENGINE\": \"django.db.backends.postgresql\",\n        \"NAME\": env(\"POSTGRES_DB\"),\n        \"USER\": env(\"POSTGRES_USER\"),\n        \"PASSWORD\": env(\"POSTGRES_PASSWORD\"),\n        \"HOST\": env(\"POSTGRES_HOST\", \"localhost\"),\n        \"PORT\": env(\"POSTGRES_PORT\", \"5432\"),\n    }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two decisions here:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>PostgreSQL from day one, not SQLite. SQLite allows things PostgreSQL rejects. For example, it does not enforce max_length on text columns. If I developed on SQLite, these bugs would hide until I deployed to RDS. I also need PostgreSQL array fields later.<\/li>\n\n\n\n<li>The variable names match the official Postgres image. The postgres Docker image reads POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD the first time it starts, and creates that database and user. I use the same names in Django. So one .env file creates the database and tells Django how to log in to it. No second set of values to keep in sync.<\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\">Secrets stay out of git<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I commit a .env.example file with placeholders only. It documents every variable the app needs. The real .env is in .gitignore. If a secret ever reaches git, it stays in the history forever, even if you delete the file later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One small detail: I generated the secret key with secrets.token_urlsafe(50), not Django&#8217;s own get_random_secret_key(). Django&#8217;s version can create characters like $ and (. A $ breaks source .env in bash, and Docker Compose reads it as a variable. token_urlsafe only uses letters, digits, &#8211; and _.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3: The Dockerfile, line by line<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is a development Dockerfile. In Phase 4 I will replace it with a multi-stage production image. But even this simple one has a few decisions that are easy to get wrong.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>FROM python:3.12-slim<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Python 3.12, to match my .python-version pin. slim removes compilers, docs and extra packages. The image is smaller and has fewer known vulnerabilities (CVEs) for a scanner like Trivy to report later.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>COPY --from=ghcr.io\/astral-sh\/uv:0.13.0 \/uv \/uvx \/bin\/<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">uv is a single binary file. COPY &#8211;from copies it out of the official uv image. That is the whole install: no pip install uv, no curl script. I pinned version 0.13.0 to match the uv on my server. If I used latest, a new uv release could change my build without any change in my repo.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ENV PYTHONUNBUFFERED=1 \\\n    PYTHONDONTWRITEBYTECODE=1 \\\n    UV_PROJECT_ENVIRONMENT=\/opt\/venv \\\n    UV_PYTHON_DOWNLOADS=never \\\n    PATH=\"\/opt\/venv\/bin:$PATH\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This block has the most interesting decision in the whole file:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>PYTHONUNBUFFERED=1 makes Python print logs immediately. Without it, logs can sit in a buffer and appear late (or never) in docker compose logs.<\/li>\n\n\n\n<li>PYTHONDONTWRITEBYTECODE=1 stops Python from writing .pyc files into my repo folder.<\/li>\n\n\n\n<li>UV_PROJECT_ENVIRONMENT=\/opt\/venv puts the virtual environment outside \/app. Here is why.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">The bind mount that hides your venv<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">By default, uv creates the venv at \/app\/.venv. But in development, Docker Compose bind-mounts my repo folder over \/app, so my code changes show up in the container immediately.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A bind mount replaces the whole folder. So the image&#8217;s \/app\/.venv would be hidden behind the .venv folder from my server. That .venv was built for my server&#8217;s Python paths, not the container&#8217;s. The result: ModuleNotFoundError for Django inside the container, even though the image built fine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Moving the venv to \/opt\/venv avoids the problem completely. The bind mount covers \/app and nothing else. The PATH line puts \/opt\/venv\/bin first, so a plain python command uses the venv&#8217;s Python with Django installed.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Dependencies first, code second<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>WORKDIR \/app\n\nCOPY pyproject.toml uv.lock .python-version .\/\nRUN uv sync --locked\n\nCOPY . .<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Docker builds images in layers, and it reuses a layer if its inputs did not change. So I copy only the dependency files first, then install. If I change only my Python code, Docker reuses the slow uv sync layer, and a rebuild takes seconds instead of minutes. If I wrote COPY . . first, every small code change would reinstall every package.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">uv sync &#8211;locked installs exactly what uv.lock says. It also fails if uv.lock does not match pyproject.toml. This catches a classic mistake at build time: adding a package but forgetting to commit the updated lockfile.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A non-root user, earlier than planned<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>ARG UID=1000\nRUN useradd --create-home --uid ${UID} app\nUSER app<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">My plan says &#8220;non-root user&#8221; in Phase 4 (hardening). I did it in Phase 1 anyway, for a practical reason.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With a bind mount, any file the container creates is owned by the container user&#8217;s UID on my server. When I run makemigrations inside the container, it writes new migration files into my repo. If the container ran as root, those files would be owned by root, and I would need sudo to edit my own code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">My user on the server has UID 1000. So the container user app also gets UID 1000. Files created inside the container are owned by me outside it. And as a bonus, the app has fewer privileges if someone ever breaks into it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How the server starts<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>EXPOSE 8006\nCMD &#91;\"python\", \"manage.py\", \"runserver\", \"0.0.0.0:8006\"]<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>EXPOSE is only documentation. It does not open any port. Compose does that.<\/li>\n\n\n\n<li>0.0.0.0 means &#8220;listen on every network interface&#8221;. The default, 127.0.0.1, only accepts connections from inside the container, so the port mapping would lead nowhere.<\/li>\n\n\n\n<li>The JSON list form (&#8220;exec form&#8221;) runs Python directly as process 1. When Docker stops the container, Python receives the stop signal. The string form wraps it in \/bin\/sh -c, the shell may not pass the signal on, and Docker waits 10 seconds before it kills the process.<\/li>\n\n\n\n<li>runserver is Django&#8217;s development server. It reloads when code changes. In Phase 4, gunicorn will replace it.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">I also added a .dockerignore file. The most important line is .env. Anything copied into an image stays readable in its layers, even if you delete it in a later layer. Secrets should reach the container at runtime, never at build time.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4: Docker Compose, and the details nobody explains<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Docker Compose runs several containers as one stack from a single YAML file. Mine has two services: db (PostgreSQL 17) and api (my Django app). In Project 01, Kubernetes will do the same job at cluster scale.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The database service<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>services:\n  db:\n    image: postgres:17\n    env_file: .env\n    volumes:\n      - pgdata:\/var\/lib\/postgresql\/data<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>postgres:17 is a pinned major version that RDS also supports. I did not use 18, because the 18 image stores its data in a different folder, and this volume path would need to change.<\/li>\n\n\n\n<li>env_file: .env passes my variables in. On the first start, with an empty volume, the image reads POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD and creates that database and user. On later starts, it ignores them, because the data already exists.<\/li>\n\n\n\n<li>pgdata is a named volume. Docker manages it, and it survives when containers are removed. docker compose down keeps the data. docker compose down -v deletes it. (Remember this one. It comes back in Part 2.)<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">There is also something missing on purpose: the db service has no ports: section. PostgreSQL is not reachable from my server or my network. Only the api container can talk to it. When I need a SQL shell, I use docker compose exec db psql.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The healthcheck and the double dollar sign<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>healthcheck:\n  test: &#91;\"CMD-SHELL\", \"pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}\"]\n  interval: 5s\n  timeout: 3s\n  retries: 10<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">pg_isready is a small Postgres tool. It only succeeds when the server accepts connections. Docker runs it every 5 seconds to decide if the container is healthy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Compose has its own variable substitution. When it sees ${POSTGRES_USER}, it tries to fill it in from the shell where I typed docker compose. In that shell, the variable is not set, so Compose would replace it with an empty string (and print a warning). The container would receive pg_isready -U -d, which is wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">$$ tells Compose: &#8220;this is a real dollar sign, leave it alone&#8221;. So the container receives ${POSTGRES_USER}, and the shell inside the container fills it in, where env_file has set the real value.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The API service<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>api:\n  build: .\n  env_file: .env\n  environment:\n    - POSTGRES_HOST=db\n  ports:\n    - \"8006:8006\"\n  volumes:\n    - .:\/app\n  depends_on:\n    db:\n      condition: service_healthy\n\nvolumes:\n  pgdata:<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three things are worth explaining here.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">1. Why POSTGRES_HOST appears twice. My .env says POSTGRES_HOST=localhost. That is correct when I run Django directly on my server with uv run. But inside a container, localhost means the container itself, and there is no Postgres in the API container.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In Compose, every service gets a hostname equal to its service name. So inside the stack, Postgres lives at db. Values under environment: override values from env_file:, so the API container gets db, and my server keeps localhost. Same .env file, correct value in both places.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">2. Why depends_on needs a condition. A plain depends_on: [db] only waits for the database container to start. But a started Postgres container may still be setting up its data folder, so the first Django query can fail. condition: service_healthy waits until the healthcheck passes. That is the real &#8220;ready&#8221;.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">3. Bind mount vs named volume. .:\/app is a bind mount: it maps my repo folder into the container, so my code edits appear immediately and runserver reloads. pgdata is a named volume: Docker-managed storage for data that must survive. Code goes in a bind mount. Data goes in a volume.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Did it work?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I checked each layer separately:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker compose ps\n# db (healthy), api Up\ndocker compose exec api python manage.py showmigrations auth\n# all &#91;X]\ndocker compose exec db psql -U jobboard -d jobboard -c '\\dt'\n# auth_user, django_migrations, ...\ncurl -I http:\/\/localhost:8006\/admin\/\n# HTTP\/1.1 302 Found<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The last check returns 302 Found, which redirects to the admin login page. That is the correct answer: the admin exists, I am not logged in, so Django sends me to log in. A 400 would mean ALLOWED_HOSTS blocked me. A 500 would mean a server error, like a database problem. A connection error would mean nothing is listening on that port.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Problems I hit (and how I fixed them)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing works the first time. Here are the two real problems from this step.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">1. The database was empty after docker compose up.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Symptom: psql \\dt said Did not find any relations. No tables at all.<\/li>\n\n\n\n<li>Cause: I had not run migrate. The connection was fine. showmigrations auth showed every migration as [ ] (not applied).<\/li>\n\n\n\n<li>Fix: docker compose exec api python manage.py migrate. After that, every line showed [X].<\/li>\n\n\n\n<li>Lesson: starting the containers does not create the tables. Migrations are a separate step. In Kubernetes, this will become its own Job.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">2. A typo that Python never complained about.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">My first Dockerfile had this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ENV PYTHONBUFFERED=1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The correct name is PYTHONUNBUFFERED, with UN. Python ignores environment variable names it does not know. So there was no error, no warning, nothing. My logs could simply arrive late or not at all in docker compose logs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is the most dangerous kind of bug: the kind that fails silently. I fixed the name, rebuilt, and checked the real value inside the container:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker compose exec api env | grep PYTHONUNBUFFERED\n# PYTHONUNBUFFERED=1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The lesson: for a setting that gives no error when it is wrong, don&#8217;t trust the file. Check the running container.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What I learned, and what comes next<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">At the end of this step, the app has no models, no login and no endpoints. A company still cannot post a job. But the foundation is ready, and every later step builds on it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The ideas I will carry into every project:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Make the safe option the default. DEBUG is off unless I turn it on. A missing secret crashes the app instead of running with a broken config.<\/li>\n\n\n\n<li>Fail loudly and early. A clear error at startup is better than a confusing error an hour later.<\/li>\n\n\n\n<li>Pin versions everywhere. Python 3.12, Django 5.2, Postgres 17, uv 0.13.0, plus a lockfile. &#8220;It works on my machine&#8221; usually means two machines had different versions.<\/li>\n\n\n\n<li>Know which layer you are in. localhost on my server and localhost in a container are different places. A bind mount can hide files that the image contains.<\/li>\n\n\n\n<li>Silent bugs are the worst bugs. A typo in an environment variable name gives no error. Check the running system, not just the file.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">In Part 2, I build the data model: a custom user with two roles, and the tables for companies, candidates, jobs and applications. I also had to delete my whole database to make it work. I will explain why, and why it was safe that time.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Why I am building a &#8220;boring&#8221; app on purpose In this series, I want to share one of my projects with you&hellip;<\/p>\n","protected":false},"author":2,"featured_media":188,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"1","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/building-job-board-api-part-1-foundation-reza-chegini-fjyvc\/","footnotes":""},"categories":[10,116],"tags":[8,111,118,25,112],"series":[117],"class_list":["post-186","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-django","tag-devops","tag-django","tag-job-board","tag-python","tag-rest-api","series-job-board-api"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/building-job-board-api-part-1-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/186","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=186"}],"version-history":[{"count":2,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/186\/revisions"}],"predecessor-version":[{"id":190,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/186\/revisions\/190"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/188"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=186"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=186"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=186"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=186"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}