← All posts

Running SQL Server on Linux for a .NET app (and why you should not open port 1433)

SQL Server has run on Linux since 2017, and it runs fine on a small VPS — the same droplet that hosts your ASP.NET Core app can hold its database too, for a solo project or a small team. The part everyone gets wrong isn’t the install, it’s what happens after: port 1433 ends up reachable from the whole internet, because nothing about the default setup stops it. Every step below was run against a real Ubuntu 24.04 box before writing it down, including the one place Microsoft’s own package still assumes Ubuntu 22.04.

1. Install SQL Server

Microsoft doesn’t ship a mssql-server-2022 package built for Ubuntu 24.04 (noble) yet — their repo config still points at the 22.04 (jammy) build, which happens to run fine on 24.04 with one library caveat covered below:

curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list \
  | sed 's/\[/[signed-by=\/usr\/share\/keyrings\/microsoft-prod.gpg /' \
  | tee /etc/apt/sources.list.d/mssql-server-2022.list
apt update
apt install -y mssql-server
ACCEPT_EULA=Y MSSQL_PID=Developer MSSQL_SA_PASSWORD='<a strong password>' \
  /opt/mssql/bin/mssql-conf -n setup accept-eula

Use MSSQL_PID=Developer for anything that isn’t production — it’s the free, fully-featured edition Microsoft ships for exactly this. On a real box, systemctl start mssql-server should show active (running); on a fresh Ubuntu 24.04 install it instead fails with:

/opt/mssql/bin/sqlservr: error while loading shared libraries: liblber-2.5.so.0: cannot open shared object file

2. The one library the jammy package needs

That error is real, and it’s not a typo in your setup — mssql-server was built against OpenLDAP 2.5’s liblber-2.5.so.0, and Ubuntu 24.04 ships OpenLDAP 2.6, which only provides liblber-2.6.so.0. Noble’s own repos don’t carry the old soname, so pull the compat library straight from Ubuntu’s jammy archive and install it alongside:

cd /tmp
curl -fsSLO http://archive.ubuntu.com/ubuntu/pool/main/o/openldap/libldap-2.5-0_2.5.20+dfsg-0ubuntu0.22.04.1_amd64.deb
dpkg -i libldap-2.5-0_2.5.20+dfsg-0ubuntu0.22.04.1_amd64.deb
systemctl restart mssql-server

systemctl status mssql-server now shows active (running), and sqlcmd -S localhost -U sa -P '<password>' -C -N -Q "SELECT @@VERSION" returns Microsoft SQL Server 2022 ... on Linux (Ubuntu 24.04...). msodbcsql18 and mssql-tools18 (the client tools, installed the same way from packages.microsoft.com/config/ubuntu/22.04/prod.list) don’t need this fix — only the server package links against liblber.

3. Set a memory limit

Left alone, SQL Server assumes it owns the box and happily grows its buffer pool to whatever RAM exists, which is a problem the moment nginx, your app, and SQL Server all share a $12/month droplet. Cap it explicitly:

systemctl stop mssql-server
/opt/mssql/bin/mssql-conf set memory.memorylimitmb 2048
systemctl start mssql-server

This isn’t the same knob as sp_configure’s “max server memory” — that value stays at its 2,147,483,647 MB default in sys.configurations no matter what you set here, because memorylimitmb works one layer lower, at SQL Server’s own Linux platform layer, and caps what the engine believes is available in the first place. Check /var/opt/mssql/log/errorlog after a restart — with memorylimitmb set to 2048 on a box with 16 GB physical RAM, it logs Detected 2048 MB of RAM, not the real total.

SQL Server Agent — the job scheduler that ships with Windows installs — is off by default on Linux ([sqlagent] enabled = false in /var/opt/mssql/mssql.conf) and most Linux installs leave it that way; use cron for scheduled work instead, the same as the backup job below.

4. Connect from .NET

dotnet add package Microsoft.Data.SqlClient
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
Server=127.0.0.1;Database=MyAppDb;User Id=myapp_user;Password=<password>;TrustServerCertificate=True;

TrustServerCertificate=True is what a fresh install needs, because mssql-conf setup doesn’t generate a certificate signed by a CA your app trusts — it’s fine for a database the app reaches only over 127.0.0.1 on the same box; if you ever terminate a real certificate in front of SQL Server itself (uncommon for this setup), drop that flag once you do.

5. Create a database and an app login

Don’t run your app as sa. Create a login scoped to one database:

CREATE DATABASE MyAppDb;
GO
CREATE LOGIN myapp_user WITH PASSWORD = '<a different strong password>';
GO
USE MyAppDb;
CREATE USER myapp_user FOR LOGIN myapp_user;
ALTER ROLE db_owner ADD MEMBER myapp_user;
GO

Run all three blocks through sqlcmd -S localhost -U sa -P '<sa password>' -C -N -Q "...", or a .sql file with -i. db_owner on its own database is normal for a single-app database; it isn’t sysadmin on the instance.

6. Never open port 1433 — use an SSH tunnel

By default SQL Server listens on 0.0.0.0:1433 — every interface, not just localhost (ss -tln will show it). If your firewall only allows 22, 80 and 443 (the standard three-rule ufw setup), that’s already enough: ufw’s default-deny policy means 1433 is never reachable from outside even though SQL Server itself is listening on it. Keep it that way. Opening 1433 to your own IP is possible but is one more thing that can be misconfigured later; a tunnel needs nothing open on the server beyond the SSH port you already have.

From your own machine:

ssh -L 14330:localhost:1433 deploy@<server-ip>

Then point SQL Server Management Studio or Azure Data Studio at 127.0.0.1,14330 with SQL login myapp_user — the traffic between your laptop and that port travels through the SSH connection, encrypted, and the server never has to expose 1433 to anything. Close the SSH session and the tunnel closes with it.

7. Backups to S3, on a schedule

BACKUP DATABASE writes a .bak file that only the mssql user can read by default — give your deploy user’s group read access once, so a cron job doesn’t need sudo:

usermod -aG mssql deploy
chmod 750 /var/opt/mssql/backup
BACKUP DATABASE MyAppDb TO DISK = '/var/opt/mssql/backup/MyAppDb.bak' WITH INIT;

Then, as deploy, ship it off the box (a local disk backup that dies with the droplet isn’t a backup):

aws s3 cp /var/opt/mssql/backup/MyAppDb.bak s3://my-backups-bucket/MyAppDb-$(date +%Y%m%d).bak

rclone copy works the same way against any S3-compatible target (Backblaze B2, Cloudflare R2) if you’d rather not use AWS at all. Wrap both commands in one script and put it on a schedule as the deploy user (crontab -e -u deploy):

0 3 * * * /usr/bin/sqlcmd -S localhost -U myapp_user -P '<password>' -C -N \
  -Q "BACKUP DATABASE MyAppDb TO DISK = '/var/opt/mssql/backup/MyAppDb.bak' WITH INIT" \
  && /usr/local/bin/aws s3 cp /var/opt/mssql/backup/MyAppDb.bak \
     s3://my-backups-bucket/MyAppDb-$(date +\%Y\%m\%d).bak

Percent signs need escaping (\%) inside a crontab line — cron treats a bare % as a newline in the command, which silently truncates everything after the first one. Add an S3 lifecycle rule on the bucket (expire objects older than 30 days, say) instead of pruning old backups by hand from a script — one less thing that can fail silently.

Test a restore (RESTORE DATABASE ... WITH REPLACE into a scratch database, or pg_restore-style into a differently-named database) at least once when you set this up — a backup you’ve never restored is a guess, not a backup.

What breaks (and why)

liblber-2.5.so.0: cannot open shared object file. Covered above — this is Ubuntu 24.04 specifically; 22.04 doesn’t hit it because it already has OpenLDAP 2.5.

Login failed for user 'sa'. mssql-conf -n setup only picks up MSSQL_SA_PASSWORD if the service isn’t already running when you set it; if you’re re-running setup, systemctl stop mssql-server first, or use mssql-conf set-sa-password directly against a stopped service.

A remote tool can’t connect at all. Check three things in order: is the service active (running); does ufw status show 1433 as anything other than absent (it shouldn’t); and is the tunnel actually up (ss -tln on your own machine should show the local port from the -L flag listening).

Memory limit “not applied”. Querying sys.configurations for “max server memory (MB)” will never show your memorylimitmb value — that’s expected, check the error log’s “Detected N MB of RAM” line instead.

Deploy it with DotDeployer

DotDeployer provisions the server, opens only 22/80/443, and gives you a firewall-rules screen for the rare case you want a database port open to one IP -- SSH tunnels are the default for everything else.

Deploy to Hetzner with DotDeployer · Supported runtimes