Back to Article List

Run SQL Server in Docker: setup and init scripts

Run SQL Server in Docker: setup and init scripts - Run SQL Server in Docker: setup and init scripts

Updated and tested 10 October 2026

Almost everyone who tries to seed a SQL Server container with schema hits the same wall. You write a Dockerfile, you set sqlcmd as the command so your .sql files get applied on first boot, you run it, and the container is gone before you finish reading the log. docker ps -a shows Exited (1), or worse Exited (0), which looks like success and isn't.

The cause is simple once you see it and invisible until then, which is presumably why the same question keeps turning up with a slightly different Dockerfile attached every few weeks. I'll take the plain container first, then volumes and a compose file with a healthcheck, then three patterns for running initialization SQL that survive a restart.

Run the official SQL Server image

The image is mcr.microsoft.com/mssql/server and the tags follow the release: 2025-latest, 2022-latest, 2019-latest. Microsoft's Docker quickstart gives you this:

docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=<password>" \
   -p 1433:1433 --name sql1 --hostname sql1 \
   -d \
   mcr.microsoft.com/mssql/server:2025-latest

Two environment variables are mandatory. ACCEPT_EULA takes any value and the container refuses to start without it. MSSQL_SA_PASSWORD sets the sa password, and if you have SA_PASSWORD sitting in an old compose file you should replace it, because the docs call it deprecated in as many words. The password has to satisfy the standard policy, which is at least eight characters using three of the four sets (uppercase, lowercase, base-10 digits, symbols), up to 128 characters. When a SQL Server Docker container exits within a second or two of starting, a password the engine rejected is far and away the most likely cause, and the complaint goes into the container log where you will not see it unless you go looking.

On the port mapping, I'd change one thing about that command. -p 1433:1433 binds to every interface on the host, so a container on a public VPS is a public SQL Server. Bind it to loopback and reach it through a tunnel:

-p 127.0.0.1:1433:1433

The host-side port is the one that belongs in the SQL Server connection string, always, and that catches people out as soon as they start a second container, because Docker refuses the second publish of 1433 and the obvious fix is -p 1434:1433. Which works. It also means the client now has to be told about 1434, and twenty minutes go by before anyone thinks to check, since the container is up and healthy and the engine inside it is listening on 1433 the way it always was.

Persist /var/opt/mssql with a named volume

Everything the engine owns lives under /var/opt/mssql: data files, logs, the mssql.conf you edit, secrets. No volume means a fresh, empty instance every time the container is recreated, which is fine for a test run and catastrophic the first time you do it to something you cared about.

docker volume create mssql-data
docker run -d --name mssql \
  -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='<password>' \
  -p 127.0.0.1:1433:1433 \
  -v mssql-data:/var/opt/mssql \
  mcr.microsoft.com/mssql/server:2025-latest

Named volumes are the right choice here, and bind mounts from the host are where this goes wrong, more so for SQL Server than for most images. Containers for SQL Server 2019 and later run as a non-root user by default. Bind-mount a directory from the host and the engine gets denied write access to it unless the ownership on the host side happens to match, so what you get is a container that starts, fails to open master.mdf and dies. Named volumes let Docker handle the ownership and the problem never appears.

If you need a bind mount anyway (backups landing somewhere your host backup agent can see, usually), read the UID off the image itself:

docker inspect --format '{{.Config.User}}' mcr.microsoft.com/mssql/server:2025-latest

File ownership gets covered properly in Microsoft's container security guide. The UID in its examples is illustration. It is not published anywhere as the image's default, so read the real number off the image you are running.

A compose file with a healthcheck

Compose is where this gets tidy, mostly because the healthcheck gives you something to depend on later. It is also where most of the broken examples live. Search for a SQL Server compose file and you get a dozen results that all seem to descend from the same 2019 blog post: SA_PASSWORD where it should now be MSSQL_SA_PASSWORD, a bind mount to ./data, a healthcheck calling /opt/mssql-tools/bin/sqlcmd at a path the image is retiring, and a single $ where compose needs two. Any one of those produces a container that looks nearly right. People copy them because the file is eighteen lines long, and eighteen lines of YAML does not look like something that can be wrong in four places at once.

services:
  mssql:
    image: mcr.microsoft.com/mssql/server:2025-latest
    container_name: mssql
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "${MSSQL_SA_PASSWORD}"
      MSSQL_PID: "Developer"
    ports:
      - "127.0.0.1:1433:1433"
    volumes:
      - mssql-data:/var/opt/mssql
    healthcheck:
      test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q 'SELECT 1' || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 40s
    restart: unless-stopped

volumes:
  mssql-data:

Note the doubled $$ in the healthcheck. Compose interpolates single-dollar variables itself, so $$MSSQL_SA_PASSWORD is how you pass the literal string through to the shell inside the container. Miss that and the healthcheck authenticates with an empty password forever, patiently, every ten seconds. I still type a single dollar sign there about as often as I get it right.

start_period: 40s is not padding. A cold start with no existing databases takes a while, and without a start period Docker counts those early failures against the retry budget and marks the container unhealthy before the engine has finished coming up. Past that, the pattern is the same as any other stateful service. Anyone who has written a Docker Compose stack for Nextcloud has already met the named volume and the healthcheck, and none of it changes because the thing inside the container happens to be a database.

sqlcmd inside the container needs -C

Since SQL Server 2022 CU 14 and SQL Server 2019 CU 28, the container images ship mssql-tools18 at /opt/mssql-tools18/bin, and the older /opt/mssql-tools/bin path is being retired. Useful, with one catch that catches everyone: tools 18 makes encryption mandatory by default, the container presents a self-signed certificate and your sqlcmd call fails with a certificate chain error before it ever authenticates. Pass -C to trust the certificate, or -No to make encryption optional. That's why -C is in the healthcheck above and in every docker exec below.

docker exec -it mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '<password>' -C

Your idea deserves better hosting

24/7 support 30-day money-back guaranteeRefund covers the VPS plan on your first order. Cancel anytime
Billing Cycle

VPS.S1

€5.32 Save  17 %
€4.43 Monthly
  • 2 vCPU AMD EPYC
  • 2 GB RAMMEMORY
  • 30 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included

VPS.S3

€13.32 Save  33 %
€8.88 Monthly
  • 4 vCPU AMD EPYC
  • 6 GB RAMMEMORY
  • 70 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included

VPS.S4

€17.76 Save  20 %
€14.21 Monthly
  • 6 vCPU AMD EPYC
  • 8 GB RAMMEMORY
  • 80 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included

EPYC VPS.P1

€7.99 Save  22 %
€6.21 Monthly
  • 2 vCPU AMD EPYC
  • 4 GB RAMMEMORY
  • 40 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P2

€15.10 Save  24 %
€11.54 Monthly
  • 2 vCPU AMD EPYC
  • 8 GB RAMMEMORY
  • 80 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P4

€26.65 Save  23 %
€20.43 Monthly
  • 4 vCPU AMD EPYC
  • 16 GB RAMMEMORY
  • 160 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P5

€35.54 Save  25 %
€26.65 Monthly
  • 8 vCPU AMD EPYC
  • 16 GB RAMMEMORY
  • 180 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P6

€53.31 Save  25 %
€39.98 Monthly
  • 8 vCPU AMD EPYC
  • 32 GB RAMMEMORY
  • 200 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

EPYC VPS.P7

€62.20 Save  29 %
€44.42 Monthly
  • 16 vCPU AMD EPYC
  • 32 GB RAMMEMORY
  • 240 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

Genoa VPS.G2

€22.21 Save  20 %
€17.76 Monthly
  • 2 vCPUAMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture. AMD EPYC G4
  • 4 GB DDR5MEMORY
  • 50 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

Genoa VPS.G4

€39.98 Save  22 %
€31.09 Monthly
  • 4 vCPUAMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture. AMD EPYC G4
  • 8 GB DDR5MEMORY
  • 100 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

Genoa VPS.G6

€79.97 Save  22 %
€62.20 Monthly
  • 8 vCPUAMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture. AMD EPYC G4
  • 16 GB DDR5MEMORY
  • 200 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

Genoa VPS.G7

€142.18 Save  22 %
€111.07 Monthly
  • 8 vCPUAMD EPYC Genoa 4th generation 9xx4 with 3.25 GHz or similar, on Zen 4 architecture. AMD EPYC G4
  • 32 GB DDR5MEMORY
  • 250 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6IPv6 is currently unavailable in France, Finland or the Netherlands. included
  • Free auto backupsIncludes one backup slot you can set to run daily, weekly or monthly.

AMD Ryzen VPS.R1

€15.10 Save  18 %
€12.43 Monthly
  • 1 dedicated CPU AMD Ryzen 9 7950X with 4.5 GHz or similar, on Zen 4 architecture. vCPU
  • 4 GB DDR5MEMORY
  • 50 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • Auto backup included

AMD Ryzen VPS.R2

€26.65 Save  17 %
€22.21 Monthly
  • 2 dedicated CPUs AMD Ryzen 9 7950X with 4.5 GHz or similar, on Zen 4 architecture. vCPU
  • 8 GB DDR5MEMORY
  • 100 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • Auto backup included

AMD Ryzen VPS.R4

€97.74 Save  18 %
€79.97 Monthly
  • 8 dedicated CPUs AMD Ryzen 9 7950X with 4.5 GHz or similar, on Zen 4 architecture. vCPU
  • 32 GB DDR5MEMORY
  • 400 GB NVMeSTORAGE
  • Unmetered bandwidth
  • IPv4 & IPv6 included IPv6 support is currently unavailable in France, Finland or the Netherlands.
  • Auto backup included

*VAT excluded.

INCLUDED WITH EVERY PLAN

✓ No setup fees ✓1 Gbps network
✓ Full root access on KVM ✓ Free server monitoring ✓ Firewall management ✓24/7 support

Why the mssql docker container exits when sqlcmd is the command

A container lives exactly as long as its PID 1. The official image runs sqlservr as PID 1, that process is the database engine, and so the container stays up as long as the engine does. Everything in this section follows from that one sentence.

Set CMD ["/opt/mssql-tools18/bin/sqlcmd", "-i", "/init/schema.sql"] in a derived image and you have replaced the engine with a client. There is now nothing listening on 1433 inside that container, sqlcmd fails to connect, it exits and the container exits with it. An ENTRYPOINT that runs sqlcmd and then sqlservr fares no better, because the client runs first against a server that does not exist yet. The canonical thread on Server Fault has been collecting answers to this for years, and it is a fair snapshot of how many plausible-looking wrong turns there are.

SQL Server has no /docker-entrypoint-initdb.d convention the way Postgres and MySQL do. A docker mssql initialization script is something you build yourself, and there are three shapes that work.

Pattern 1: run sql script on startup with an entrypoint wrapper

Start the engine in the background, poll until it answers, apply your scripts, then hand the shell back to the engine process so the container's lifetime still tracks the database. Save this as entrypoint.sh:

#!/bin/bash
set -e

/opt/mssql/bin/sqlservr &
sqlservr_pid=$!

for i in $(seq 1 60); do
  if /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -C -Q "SELECT 1" >/dev/null 2>&1; then
    echo "engine is up, applying init scripts"
    for f in /init/*.sql; do
      [ -e "$f" ] || continue
      echo "applying $f"
      /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -C -b -i "$f"
    done
    break
  fi
  sleep 2
done

wait "$sqlservr_pid"

The two lines doing the real work are the backgrounded sqlservr and the final wait. Drop the wait and the script ends, PID 1 exits and the container goes down with the engine still running inside it, which produces the most confusing five minutes of your week. -b on the sqlcmd calls makes it return a non-zero exit code on a T-SQL error; without it a broken migration reports success cheerfully.

chmod +x entrypoint.sh
docker run -d --name mssql \
  -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='<password>' \
  -p 127.0.0.1:1433:1433 \
  -v mssql-data:/var/opt/mssql \
  -v "$PWD/entrypoint.sh:/usr/local/bin/entrypoint.sh:ro" \
  -v "$PWD/init:/init:ro" \
  --entrypoint /usr/local/bin/entrypoint.sh \
  mcr.microsoft.com/mssql/server:2025-latest

Bind-mounting the script keeps the image stock and keeps the default non-root user intact. Build a derived image instead and USER root at the top of the Dockerfile throws that user away, so you have to put it back explicitly, which is where the UID guessing starts again. Make your scripts idempotent, because this wrapper runs on every start and not only on the first: IF DB_ID('appdb') IS NULL CREATE DATABASE appdb; at the top of each file costs nothing.

Pattern 2: a one-shot init service in compose

The wrapper is fine for a single container. For a stack I prefer to keep the database container completely stock and put the initialization in its own service that waits on the healthcheck. Add this alongside the mssql service above:

  mssql-init:
    image: mcr.microsoft.com/mssql/server:2025-latest
    depends_on:
      mssql:
        condition: service_healthy
    environment:
      MSSQL_SA_PASSWORD: "${MSSQL_SA_PASSWORD}"
    volumes:
      - ./init:/init:ro
    entrypoint: ["/bin/bash", "-c"]
    command: >
      for f in /init/*.sql; do
        /opt/mssql-tools18/bin/sqlcmd -S mssql -U sa -P "$$MSSQL_SA_PASSWORD" -C -b -i "$$f" || exit 1;
      done
    restart: "no"

condition: service_healthy is what makes this work, and it is why the healthcheck is in the file at all. The init service starts only once the engine answers SELECT 1, then runs to completion and stops. docker compose ps shows it afterwards as exited with code 0. Reusing the SQL Server image for the init service is a small trick: it already contains sqlcmd, so you skip building anything at all. Note -S mssql and not localhost: it is a different container now, and the service name is the hostname.

Pattern 3: an init container in Kubernetes

The pattern people reach for on Kubernetes is an init container in the SQL Server pod itself, and that one doesn't work. Init containers run to completion before the main container starts, so it would be waiting for an engine that hasn't been created. Put the init container in your application's pod instead, where it blocks the app until the schema exists, or run a Job:

apiVersion: batch/v1
kind: Job
metadata:
  name: mssql-init
spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
        - name: apply-schema
          image: mcr.microsoft.com/mssql/server:2025-latest
          command: ["/bin/bash", "-c"]
          args:
            - for f in /init/*.sql; do /opt/mssql-tools18/bin/sqlcmd -S mssql -U sa -P "$MSSQL_SA_PASSWORD" -C -b -i "$f" || exit 1; done
          env:
            - name: MSSQL_SA_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mssql-secret
                  key: sa-password
          volumeMounts:
            - name: init-sql
              mountPath: /init
      volumes:
        - name: init-sql
          configMap:
            name: mssql-init-sql

A Job retries on failure and records its outcome, which beats a shell loop you have to sit and watch. The same thing works as a Helm hook. I use the Job, and that is preference more than argument, so I will leave it there.

Back up a database from inside the container

Backups run in the container and land on the volume, then you pull the file out.

The default backup directory on Linux is /var/opt/mssql/data, the same directory as your data files, so it is already sitting on the named volume from earlier:

docker exec mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '<password>' -C \
  -Q "BACKUP DATABASE [demodb] TO DISK = N'/var/opt/mssql/data/demodb.bak' WITH NOFORMAT, NOINIT, NAME = 'demodb-full', SKIP, NOREWIND, NOUNLOAD, STATS = 10"

docker cp mssql:/var/opt/mssql/data/demodb.bak ./demodb.bak

Microsoft's own example in the Linux backup and restore guide leaves -P off deliberately so sqlcmd prompts for the password and it never lands in your shell history. Worth copying that habit for anything interactive. Copying the .bak out beats snapshotting the volume, because a file-level copy of a running database gives you a backup that restores about half the time.

Save your best work

Send your backups to a storage VPS with room for years of archives, unmetered bandwidth and a cost per gigabyte that stays low.

Docker or a direct install

I run SQL Server in Docker for development, CI and anything I expect to throw away. For a single production database on one machine I install it on the host: SQL Server on Ubuntu 24.04 gives you mssql-conf, systemd and a memory limit you can tune without rebuilding anything, and the container buys you nothing you did not already have. Both come out well ahead of the Windows Server installation on resource usage.

LumaDock's Docker VPS template ships with Docker pre-installed on AMD EPYC with NVMe storage, so Docker itself is already installed when you log in. The SQL Server licence is your side of the deal, as always, so use a free edition or bring a key.

One more time on the log, since that is where every one of these failures ends up. Common SQL Server errors behave identically inside a container, and docker logs mssql holds the same engine startup output you would read out of /var/opt/mssql/log/errorlog on a bare install. A rejected password, a permission error on the data directory, an unsupported filesystem under the volume: the log names which one it was. The reason it feels like it does not is that the process died before anything you would recognise reached your terminal.

Questions?

Can I run the SQL Server Docker image on an Apple silicon Mac?

Not in a supported way. Microsoft supports the SQL Server container images only on Linux hosts with Intel or AMD x86-64 CPUs, and states that emulation such as Rosetta 2 or QEMU isn't tested or supported. A container you get running under emulation on an M-series Mac is outside that support, so keep it for experiments. For anything you depend on, run the container on an x86-64 Linux server and connect to it from the Mac.

Jörg L. Günther
About the author

Jörg L. Günther

Jörg has spent much of his career in enterprise software, working first with database deployments and customer environments, then in technical training and documentation. Since then he has written for companies working in virtualisation, database tooling and cloud infrastructure. The subjects he takes on often have a large operational component: database migrations, high-availability setups, storage configuration and procedures carried out on live systems. Jörg lives near Frankfurt. He plays piano and cycles for transport as much as for recreation. He's also been doing woodworking for several years - he says most of the furniture in his study is his own work.

Deploy your next project in seconds

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.