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

