Skip to content
    DockerFastAPIDeployment

    Dockerize a FastAPI Application for Local Development and Production Deployment

    I wanted to Dockerize my FastAPI application and keep separate configurations for local development and production. My application was a CRM backend using FastAPI, PostgreSQL, and...

    •Oct 8, 2026•
    11 min read
    •
    9 views
    Dockerize a FastAPI Application for Local Development and Production Deployment

    I wanted to Dockerize my FastAPI application and keep separate configurations for local development and production.

    My application was a CRM backend using FastAPI, PostgreSQL, and Alembic. My goal was to run it locally with Docker, push the changes to GitHub, and deploy the same project on my Ubuntu VPS.

    During this process, I had several doubts about environment variables, database connections, YAML configuration, and port settings. After deployment, I also faced an issue where the API worked inside the VPS but could not be accessed from outside.

    This is how I worked through the setup and what I learned.

    1. Keeping Local and Production Configuration Separate

    I started by learning how to install Docker and check that Docker Compose was available.

    Then my first question was how to keep Docker files inside my project for both local development and deployment.

    My setup used:

    text
    Dockerfile
    compose.yaml
    compose.production.yaml
    .env
    .env.production

    The Dockerfile builds the application image. The two Compose files control how the services run in each environment.

    Both environments had three main services:

    ServicePurpose
    dbRun PostgreSQL
    migrateRun Alembic migrations
    apiRun FastAPI

    For local development, I wanted code changes to appear quickly. My API service used:

    yaml
    command:
      ["uvicorn", "app.main:app", "--host", "0.0.0.0",
       "--port", "8000", "--reload"]
    
    volumes:
      - ./app:/app/app:ro

    The mount makes my local application files available inside the container. The --reload option restarts the development server when the code changes.

    My local API port mapping was:

    yaml
    ports:
      - "127.0.0.1:8000:8000"

    For production, I did not use development reload or source-code mounts. The application ran from the built image.

    This gave me one project with separate settings for development and deployment.

    2. My Database Doubt Was About More Than localhost

    One of my main doubts was about having different database values in .env and Compose.

    Initially, my .env contained:

    env
    DATABASE_URL=postgresql+asyncpg://crm:crm@localhost:5432/crm

    But my API and migration services had this explicit setting:

    yaml
    env_file: .env
    
    environment:
      DATABASE_URL: postgresql+asyncpg://crm:crm@db:5432/crm

    I wanted to understand which value Docker would actually use.

    Then I asked a more specific question: what if I changed .env to this?

    env
    DATABASE_URL=postgresql+asyncpg://crmnew:crmnew@localhost:5432/crm_new

    My PostgreSQL service still had:

    yaml
    environment:
      POSTGRES_DB: crm
      POSTGRES_USER: crm
      POSTGRES_PASSWORD: crm

    Would Docker use the new database values automatically?

    The answer was no.

    There were two separate things to understand.

    First, the explicit environment value in my API service overrides the same variable from env_file. So the API container would still receive:

    text
    postgresql+asyncpg://crm:crm@db:5432/crm

    Second, PostgreSQL does not create a database based on the API’s DATABASE_URL.

    The PostgreSQL container uses POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD when initializing a new database.

    The API uses DATABASE_URL to connect to that database.

    Changing only the URL does not automatically change the PostgreSQL database or user. The connection details need to match the actual database setup.

    These were questions I checked while configuring the application. I did not have a confirmed database connection failure in the shared history.

    3. Why Docker Uses db Instead of localhost

    The hostname was another part of the same discussion.

    Inside the API container, localhost means the API container itself. It does not mean the PostgreSQL container.

    My PostgreSQL service was named db:

    yaml
    services:
      db:
        image: postgres:17

    Docker Compose allows containers on its network to connect using service names. So my API and migration containers needed to use:

    text
    db:5432

    The address depends on where the application runs:

    Application locationDatabase address
    Running directly on my computer, using the published database portlocalhost:5432
    Running inside Docker Composedb:5432

    This applies to local Docker development too. It is not only a production rule.

    Later, I changed my PostgreSQL configuration to read values from .env:

    yaml
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

    This made it easier to manage the database settings.

    However, any database URL still hardcoded under environment would continue to override the URL in .env. I needed to remember that when changing credentials.

    4. Starting PostgreSQL, Migrations, and the API in Order

    My application uses Alembic to manage database changes.

    I kept migrations in a separate service:

    yaml
    migrate:
      build: .
      image: async-crm:local
      command: ["alembic", "upgrade", "head"]
      env_file: .env
    
      depends_on:
        db:
          condition: service_healthy
    
      restart: "no"

    The migration service waits until PostgreSQL passes its healthcheck.

    Then the API waits for migrations to finish successfully:

    yaml
    depends_on:
      migrate:
        condition: service_completed_successfully

    The startup order was:

    text
    PostgreSQL becomes healthy
              ↓
    Alembic runs migrations
              ↓
    Migrations finish successfully
              ↓
    FastAPI starts

    I also reviewed the database healthcheck. Instead of keeping the username and database name hardcoded, the suggested version was:

    yaml
    healthcheck:
      test:
        ["CMD-SHELL",
         "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10

    The double $ allows those variables to be expanded inside the container.

    Another useful point was that the migration container is expected to stop after finishing. An exit code of 0 means it completed successfully.

    5. Understanding <<: *app in My Production File

    While reviewing my production Compose file, I saw:

    yaml
    api:
      <<: *app

    I asked what <<: *app meant.

    My file defined common application settings like this:

    yaml
    x-app: &app
      image: ${CRM_IMAGE:-async-crm:production}
      build: .
      environment:
        DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production}
        JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env.production}
        ACCESS_TOKEN_MINUTES: ${ACCESS_TOKEN_MINUTES:-60}

    Both the API and migration services reused those settings.

    I learned that this is YAML syntax:

    • &app defines an anchor named app.
    • *app refers to that anchor.
    • <<: merges those settings into the service.

    This helped me understand why the same image, environment variables, and other settings did not need to be repeated for both services.

    6. What Changed in Production

    My production configuration included settings such as:

    yaml
    init: true
    read_only: true
    
    tmpfs:
      - /tmp:size=64m,mode=1777
    
    cap_drop:
      - ALL
    
    security_opt:
      - no-new-privileges:true

    It also limited the size and number of container log files.

    The application container had a read-only filesystem, with /tmp available for temporary files. This meant I needed to consider whether the application tried to write uploads or files somewhere else.

    These settings were reviewed during the discussion. They were not confirmed causes of an error.

    Another difference was PostgreSQL access.

    Locally, I published port 5432 so I could connect from my computer. In production, I did not publish the database port. The API and migrations could still connect through db:5432 on the Docker network.

    PostgreSQL data was stored in a named volume:

    yaml
    volumes:
      - crm_data:/var/lib/postgresql/data

    This kept the data separate from the container. I also learned to avoid removing that volume accidentally, especially with docker compose down -v.

    7. Moving the Project to My VPS

    After pushing my Docker changes to GitHub, I pulled them onto the server.

    Docker was already installed on the VPS.

    I created .env.production with the database settings, JWT secret, image name, and API port settings.

    The production database URL used db, and its credentials matched the PostgreSQL settings.

    The next steps were to validate the configuration, build the image, and start the services.

    That was where I faced my first actual command error.

    8. The Missing Environment Variables Error

    I ran:

    bash
    docker compose -f compose.production.yaml ps

    Compose returned errors saying required variables were missing, including:

    text
    DATABASE_URL
    JWT_SECRET
    POSTGRES_PASSWORD

    I checked the directory with ls -la. The .env.production file was already there.

    So the issue was not that the file was missing. Compose had not been told to load it.

    My production file used expressions such as:

    yaml
    DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production}

    Compose needs the value while processing the YAML file.

    The correct command was:

    bash
    docker compose -f compose.production.yaml \
      --env-file .env.production ps

    This helped me understand another difference:

    SettingPurpose
    Service-level env_filePass variables into a container
    Command-level --env-fileProvide values for Compose variable expressions

    I then validated the configuration:

    bash
    docker compose -f compose.production.yaml \
      --env-file .env.production config

    This time, Compose loaded the values and displayed the resolved configuration.

    9. Deployment Worked, but I Repeated the Same Command Mistake

    I started the production services:

    bash
    docker compose -f compose.production.yaml \
      --env-file .env.production up -d --build

    The output showed:

    text
    Image async-crm:production               Built
    Container async-crm-production-db-1      Healthy
    Container async-crm-production-migrate-1 Exited
    Container async-crm-production-api-1     Started

    The build completed, PostgreSQL became healthy, and the API started after the migration service finished.

    Then I ran ps again without --env-file .env.production.

    The same missing-variable errors appeared.

    This was confusing because deployment had just succeeded. But the status command was failing while processing the Compose file, before it could show the containers.

    I learned to use the complete production command consistently:

    bash
    docker compose -f compose.production.yaml \
      --env-file .env.production <command>

    That included commands for status, logs, and deployment.

    10. Confusion Between Host Port 8003 and Container Port 8000

    Next, I tried accessing the application through the VPS public IP on port 8000.

    It did not open. Since Nginx was already configured, I wondered whether Nginx was causing the issue.

    When I checked the running containers, the API mapping showed:

    text
    127.0.0.1:8003->8000/tcp

    This told me two things:

    1. The VPS port was 8003.
    2. That port was bound to the VPS’s localhost address.

    The application still ran on port 8000 inside the container.

    The mapping meant:

    text
    VPS port 8003 → Container port 8000

    I did not need to change the application’s internal port to 8003.

    My Nginx syntax check also succeeded. However, that only confirmed the configuration syntax was valid. It did not confirm that requests were reaching the correct upstream port.

    If Nginx on the host forwards requests to this API, its upstream needs to use the published host port:

    nginx
    proxy_pass http://127.0.0.1:8003;

    11. The API Worked Inside the VPS but Failed From Outside

    I clarified that I wanted direct access through the VPS IP and port 8003.

    I tested the API inside the VPS:

    bash
    curl -I http://127.0.0.1:8003/docs

    The response was:

    text
    HTTP/1.1 200 OK
    server: uvicorn

    But this still failed from my computer:

    text
    http://<VPS-IP>:8003/docs

    The successful local response was useful evidence. FastAPI was responding through Docker on the VPS.

    The remaining issue was how outside requests could reach it.

    My environment still had:

    env
    API_BIND_ADDRESS=127.0.0.1
    API_PORT=8003

    That created a localhost-only port mapping.

    For the direct external access I wanted, the required setting was:

    env
    API_BIND_ADDRESS=0.0.0.0
    API_PORT=8003

    After changing it, the container needed to be recreated:

    bash
    docker compose -f compose.production.yaml \
      --env-file .env.production up -d --build

    The expected port mapping was then:

    text
    0.0.0.0:8003->8000/tcp

    At the end of the discussion, I realised I had kept 127.0.0.1 and had not changed it. That explained why direct access from outside was failing.

    12. Checking UFW and Understanding Nginx’s Role

    During troubleshooting, I checked:

    bash
    sudo ufw status

    It returned:

    text
    Status: inactive

    I also ran:

    bash
    sudo ufw allow 8003/tcp

    The command updated the rules, but UFW was inactive. That did not change the localhost-only Docker binding.

    The VPS provider’s firewall was another possible check if external access still failed after changing the binding. The history did not confirm a provider firewall problem.

    I also learned that Nginx being installed does not automatically block direct access to port 8003.

    There are two different access paths:

    text
    Through Nginx:
    Browser → Nginx → 127.0.0.1:8003 → Container:8000
    
    Direct access:
    Browser → VPS-IP:8003 → Container:8000

    For access through Nginx on the VPS, keeping the API bound to 127.0.0.1 can make sense.

    For direct external access, the API port needs an externally reachable binding, along with network rules that allow the connection.

    What This Process Taught Me

    The main learning was to check the actual configuration and command output instead of assuming what Docker was using.

    My .env values could be overridden by Compose. A production environment file could exist without being loaded. A deployment could succeed while a later status command failed. And an API could return 200 OK inside the VPS while remaining unavailable from outside.

    By the end of the discussion, I had confirmed that the image built, PostgreSQL became healthy, and FastAPI responded inside the VPS. I had also identified the unchanged 127.0.0.1 binding as the reason direct external access failed.

    The shared history ends with that finding and the required fix. It does not include a successful external test after the change.

    For me, this Docker setup became a practical way to understand environment variables, database connections, migration order, and the difference between a running application and an application that outside users can reach.

    J
    Written by

    Jobi S S

    Portfolio

    admin

    Sharing technical insights, engineering concepts, and practical modern software development guides.

    Community Discussion

    Enjoyed this read? Show your support or share your thoughts.

    Comments (0)

    No comments yet. Be the first to comment!

    📬 Enjoyed this article?

    Get new posts on Django, FastAPI, and system design straight to your inbox. No spam — unsubscribe whenever you want.