Skip to main content

Seeding Initial Data Using Docker Compose and SQL Scripts

Seeding Initial Data Using Docker Compose and SQL Scripts
Table of Contents

Introduction
#

Nothing kills momentum on a new project like cloning the repo, running docker compose up, and landing on an empty database. No roles, no permissions, no login, so nothing works until someone reads the wiki and hand-runs a SQL script. On the Contact Management Application I wanted docker compose up to be the whole setup: containers come up, the schema seeds itself, and you can log in.

This post shows how the SQL Server container seeds its own baseline data from mounted scripts, and it is honest about the one part of that setup that is a hack.

What’s covered
#

  • Why seeding baseline data belongs in container startup, not in a manual step.
  • Mounting SQL seed scripts into the MS SQL Server container with Docker Compose.
  • Writing the seed script for roles, permissions, and their mappings.
  • Verifying the seed ran, and where the startup approach is fragile.
Part of a series building the Contact Management Application, a sample project that walks through Clean Architecture in .NET end to end. This post covers container-side database seeding.

1. Why Data Seeding is Important
#

Data seeding is the process of pre-populating the database with initial data that is necessary for the application to function, or for testing purposes. Some of the main reasons for using data seeding include:

  • Initial configuration data: Populating lookup tables or default values (e.g., roles, permissions).

  • Test data: Providing sample data for developers or testers to interact with.

  • Consistent environments: The database is in a known state every time the application starts, which makes debugging and test runs repeatable.


2. Using Docker Compose to Seed Data
#

Docker Compose allows you to orchestrate containers, such as MS SQL Server and your API, and automate the data seeding process. To seed the database with initial data, you can use SQL scripts that run when the MS SQL Server container starts.

2.1 Extending the Docker Compose File for Data Seeding
#

We will update the docker-compose.yml file to include a volume that will mount the SQL seed scripts to the MS SQL Server container.

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: debug.dockerfile
    command: ["npm", "run", "start:debug"]
    ports:
      - 4200:4200
      - 49153:49153
    volumes:
      - ./frontend:/app
      - /app/node_modules
    stdin_open: true
    tty: true
    depends_on:
      - api
    networks:
      - mssql_network
 
  api:
    build:
      context: ./backend/src
      dockerfile: Debug.Dockerfile
    command: ["dotnet", "watch", "--project", "Contact.Api/Contact.Api.csproj", "run", "--urls", "http://0.0.0.0:5000"] 
    ports:
      - 5000:5000

      
    environment:
      - ASPNETCORE__ENVIRONMENT=${ENVIRONMENT}
      - DOTNET_SKIP_POLICY_LOADING=false
      - AppSettings__ConnectionStrings__DefaultConnection=Server=${SQL_SERVER};Database=${SQL_DATABASE};User ID=${SQL_USER};Password=${SQL_PASSWORD};Trusted_Connection=False;Encrypt=False;
      - AppSettings__Secret=${JWT_SECRET}
      - AppSettings__Issuer=${JWT_ISSUER}
      - AppSettings__Audience=${JWT_AUDIENCE}
      - AppSettings__PasswordResetUrl=${PASSWORD_RESET_URL}
      - SmtpSettings__SmtpServer=${SMTP_SERVER}
      - SmtpSettings__Port=${SMTP_PORT}
      - SmtpSettings__Username=${SMTP_USERNAME}
      - SmtpSettings__Password=${SMTP_PASSWORD}
      - SmtpSettings__FromEmail=${SMTP_FROM_EMAIL}
      - SmtpSettings__EnableSsl=${SMTP_ENABLE_SSL}
    volumes:
      - ./backend/src:/app
      - ~/.vsdbg:/remote_debugger:rw
    depends_on:
      - mssql
    networks:
      - mssql_network
      
  mssql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: sqlserver_express
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_PID=Express   # Specifies the edition to run as Express
      - MSSQL_SA_PASSWORD=${SQL_PASSWORD}   # Set the SA (System Administrator) password
    ports:
      - "1433:1433"  # Expose SQL Server port 1433
    volumes:
      - mssql_data:/var/opt/mssql  # Persist database data outside of the container
      - ./backend/scripts:/scripts  # Mount for SQL scripts
    entrypoint:
      - /bin/bash
      - -c
      - |
        /opt/mssql/bin/sqlservr & sleep 15s;
        /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P ${SQL_PASSWORD} -d master -i /scripts/seed-data.sql; wait
    networks:
      - mssql_network

volumes:
  mssql_data: # Named volume to persist data

networks:
  mssql_network:
    driver: bridge

Explanation:
#

  • volumes: The mssql service mounts the host ./backend/scripts directory into the container at /scripts. The entrypoint starts SQL Server in the background, waits 15 seconds, then runs sqlcmd against seed-data.sql.
Watch out: sleep 15s is a race, not a readiness check. It assumes SQL Server accepts connections within 15 seconds; on a cold image pull or a slow machine it will not, and the seed then runs against a server that is not ready yet. A healthcheck on the mssql service with depends_on: condition: service_healthy, or a retry loop around sqlcmd, is the correct fix.

3. Writing SQL Seed Scripts
#

The SQL seed scripts are responsible for inserting the initial data into your database. For example, we can create scripts to insert data into roles, permissions, and other necessary tables.

Create the directory ./scripts/ and add the SQL script files.

Example seed-data.sql
#

USE ContactDb;

-- Insert default roles
INSERT INTO Roles (Id, Name, CreatedOn)
VALUES (NEWID(), 'Admin', GETDATE()),
       (NEWID(), 'User', GETDATE());

-- Insert default permissions
INSERT INTO Permissions (Id, Name, CreatedOn)
VALUES (NEWID(), 'Contacts.Create', GETDATE()),
       (NEWID(), 'Contacts.Update', GETDATE()),
       (NEWID(), 'Contacts.Delete', GETDATE()),
       (NEWID(), 'Contacts.Read', GETDATE());

-- Map Admin role to permissions
DECLARE @AdminRoleId UNIQUEIDENTIFIER;
SET @AdminRoleId = (SELECT Id FROM Roles WHERE Name = 'Admin');

INSERT INTO RolePermissions (Id, RoleId, PermissionId, CreatedOn)
SELECT NEWID(), @AdminRoleId, p.Id, GETDATE()
FROM Permissions p;

Explanation:
#

  • The script seeds the Roles and Permissions tables with default data.

  • The Admin role is associated with all permissions for contacts (e.g., create, update, delete, read).

For full script for this project please refer seed-data.sql


4. Automating the Seeding Process
#

When the MS SQL Server container starts, the entrypoint runs the SQL scripts under /scripts/ (mounted via the volume), so the database comes up already seeded.

4.1 Adding Seed Scripts to Docker Compose Workflow
#

To automate this process:

  1. Put all the SQL scripts in ./scripts/.

  2. Update the entrypoint command:

 entrypoint:
      - /bin/bash
      - -c
      - |
        /opt/mssql/bin/sqlservr & sleep 15s;
        /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P ${SQL_PASSWORD} -d master -i /scripts/seed-data.sql; wait

Use the docker-compose up command to start both the API and the database containers. The database container runs the seed scripts on startup.

4.2 Running Docker Compose
#

docker-compose up --build

Once the containers are running, the seed scripts will be executed, and you can access the MS SQL Server database with the pre-populated data.


5. Verifying the Seed Data
#

You can verify that the data has been correctly seeded by accessing the MS SQL Server container and querying the database.

5.1 Connecting to the MS SQL Server Container
#

To connect to the MS SQL Server container, use the following command:

docker exec -it <db-container-name> /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourPassword

Once connected, you can run queries to verify the data has been seeded:

USE ContactDb;
SELECT * FROM Roles;
SELECT * FROM Permissions;
SELECT * FROM RolePermissions;

These queries will return the data that was inserted by the seed scripts.


6. Why bother wiring this into Docker
#

  • Consistency across environments. Every environment (local, testing, staging, production) starts from the same baseline, which cuts down on “works on my machine” surprises.
  • One-command onboarding. The seed runs whenever the database container starts, so a new developer or a CI job needs nothing beyond docker compose up.
  • Reproducibility. The seed data lives in version-controlled SQL, so the same starting state reproduces anywhere the scripts run.

Takeaways
#

  • Seeding at container startup turns docker compose up into the whole onboarding step: no wiki, no manual scripts.
  • Mount the SQL files as a volume and run them from the entrypoint so the data lands as soon as SQL Server is up.
  • The sleep 15s timing is the weak link. Move to a healthcheck with service_healthy or a sqlcmd retry loop before this reaches CI or anything shared.
  • Keep the inserts idempotent (guard with NOT EXISTS) if the container can restart against the persisted mssql_data volume, or the seed will pile up duplicates.

Next in the series: error handling and exception management in the API. The full project is on GitHub: Contact Management Application.

Related

Dockerizing the .NET Core API, Angular and MS SQL Server

··10 mins
Getting the Contact Management Application running used to mean installing the .NET SDK, Node, and a local SQL Server, then hoping the versions matched mine. That is three toolchains of setup before anyone sees a login page. Containerizing the stack collapses all of it into docker-compose up --build: the API, the Angular frontend, SQL Server, and an nginx load balancer come up together, wired the same way on every machine. This post walks through the Dockerfiles and compose files that make that work, including the parts I would do differently today.

Dependency Injection Setup Across Layers

··6 mins
Program.cs is where service registration goes to rot. It starts at five lines, then every feature adds its repositories, validators, and settings bindings until nobody can tell which layer owns what. The Contact Management Application avoids that with one rule: each layer registers its own services through an extension method, and Program.cs only calls those methods. This post walks through that setup layer by layer, using the real code, including two spots where the real code deserves a warning label.

Handling Authorization and Role-Based Access Control (RBAC)

··19 mins
Introduction # Static role checks ([Authorize(Roles = "Admin")]) fall apart the first time someone asks you to add a permission without a redeploy. Once roles and permissions have to change at runtime, hard-coded role attributes become a liability. The Contact Management Application takes a different route: a dynamic policy provider that builds authorization policies from the database at request time, covering both the backend API and the Angular frontend, wired into JWT authentication without breaking the separation of concerns Clean Architecture expects.