Database security for a small team comes down to five concrete practices, not a compliance framework: the database should not be reachable from the public internet, each application should connect with its own user and its own minimal privileges rather than sharing a root account, connections should use TLS, backups should exist and have been restored at least once to prove they work, and credentials should live in environment variables or a secrets manager, never committed to the repository. Enterprise glossary pages list the CIA triad and a dozen threat categories; a small team gets more from doing these five things correctly first.
Most database security writing reads like a compliance checklist aimed at an organization with a dedicated security team. A small team does not need the whole framework on day one; it needs the handful of practices that actually stop the incidents that happen to small teams: an exposed instance, a shared credential, a backup nobody ever tested.
Table of contents
- Public exposure: the most common mistake
- One user per app, minimal privileges
- TLS in transit
- Backups you have actually tested
- Secrets out of the repository
- Connection limits and runaway queries
- What a small team can skip, for now
- How this fits the rest of the stack
- FAQ
Public exposure: the most common mistake
A database with a public IP address and just a password gets scanned within hours of coming online, automated scanners look for exactly this. The fix is not a stronger password; it is removing the public exposure entirely: a private network that only the application can reach, or at minimum a firewall rule restricting connections to known application IPs.
A concrete failure case: a developer opens a database’s port to the internet temporarily to connect from a laptop while debugging, intending to close it again afterward, and forgets. Automated scanners find that window within hours, sometimes minutes, and a database with a weak or reused password behind it is compromised before anyone notices the port was ever open. A private network removes this failure mode entirely, since there is no public address for a forgotten firewall rule to expose in the first place.
The equivalent mistake with a firewall rule instead of full public exposure is scoping it too broadly: allowing an entire cloud provider’s IP range instead of the application’s specific address, because it was easier to get working during setup. That is still a meaningfully smaller surface than the open internet, but it is worth tightening once the application’s actual, stable IP is known rather than leaving the broad rule in place indefinitely.
One user per app, minimal privileges
Never share a root or superuser account across applications or people. Create a scoped user per application with only the privileges that application’s schema needs, and revoke superuser from it entirely.
CREATE USER app_billing WITH PASSWORD 'change-me';
GRANT CONNECT ON DATABASE billing TO app_billing;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA public TO app_billing;
A leaked credential for one scoped user exposes one application’s tables. A leaked root credential exposes everything sharing that database, which is the actual reason this practice matters more than its simplicity suggests. The same pattern applies to people, not just applications: a contractor who needs read access to debug a report should get a read-only user for exactly that, not the same login the production application uses to write data.
Revoking privileges an application no longer needs is worth revisiting periodically, not just granting them once at setup. A feature that used to write to a table and got removed months ago often leaves its write grant behind unless someone actively audits and trims it, which quietly widens the blast radius of a future leaked credential for no ongoing benefit.
TLS in transit
An unencrypted connection means query traffic, including credentials in some connection flows, is sniffable on a shared or cloud network. Require TLS on the connection string, sslmode=require for Postgres, an equivalent SSL flag for MySQL, and where possible reject non-TLS connections at the server configuration level rather than merely offering encryption as an option a misconfigured client can skip.
The failure case to watch for is a client library that silently falls back to an unencrypted connection when the TLS handshake fails, rather than refusing to connect at all. That behavior turns a misconfiguration into a silent downgrade instead of a visible error, which is exactly backwards: a connection that cannot verify TLS should fail loudly, not quietly proceed without it, especially on a network you do not fully control. Test the failure path deliberately, not just the happy path where the certificate is already valid and everything connects on the first try.
Backups you have actually tested
A backup that has never been restored is a hope, not a backup. Schedule automated dumps, keep more than one, and store them somewhere other than the same box as the live database. Then actually run a restore at least once, into a scratch database, never production, so the first time you find out whether the backup works is not during a real incident.
A specific scenario worth testing against: a dump that completes without error but was taken mid-migration, capturing a schema in a half-changed state that will not actually restore cleanly. This is a genuine failure mode, not a hypothetical one, and it only surfaces during an actual restore attempt, never by checking that the backup job’s log says success.
Keeping more than one backup generation matters for a different reason than hardware failure: a slow, silent data corruption or a bad application bug that writes wrong values gradually can end up baked into the most recent backup by the time anyone notices. Having a few days or weeks of history to restore from gives you a version of the data from before the problem started, which a single most-recent backup cannot.
Secrets out of the repository
A credentials file committed once is not fixed by deleting it afterward, git history keeps it indefinitely unless the history itself is rewritten. Use environment variables injected by the host rather than a checked-in config file, add a .gitignore entry from the first commit, and rotate any credential that ever touched a commit, even one that was deleted right after.
The scenario that catches teams out: a .env.example file meant as a template gets copied to .env for a quick local test, real credentials get filled in, and it gets committed once by accident before anyone notices the .gitignore entry only covered the example file’s name, not the real one. Checking that both the example and the real filename are actually ignored is worth doing once, explicitly, rather than assuming it from the file’s presence in the repository.
A private repository is not a mitigating factor here. Anyone who ever gets read access to that repository, a new hire, a contractor, a CI system with broader permissions than it needs, inherits every credential in its history, not just the ones in the current commit. Treat a committed credential as compromised the moment it is pushed, regardless of who could theoretically see it.
Connection limits and runaway queries
A leaked connection pool or a bad migration can quietly exhaust a database’s connection limit and take down every application sharing it, not just the one that caused it. Set a sane maximum connections figure and size each application’s connection pool against it, and know that number before an incident forces you to learn it under pressure.
A concrete example of how this happens: a deploy introduces a code path that opens a new database connection per request instead of reusing one from a pool, works fine at low traffic, and exhausts the connection limit the moment traffic increases even slightly. The fix is usually a one-line pooling configuration, but finding it during an outage is much harder than catching it in a review, which is the actual argument for knowing your connection limits ahead of time.
This is also a reason to avoid a single shared database across many unrelated applications without separate connection budgets for each. One misbehaving application should not be able to starve every other application’s ability to connect at all, and a connection limit set and monitored per application, not just globally, is what keeps that isolation real.
What a small team can skip, for now
Full audit logging, row-level encryption, hardware security modules and formal compliance programs are real practices and usually overkill before there is a specific driver for them, a customer contract or a regulation that actually requires it.
The five practices above catch what actually happens to small teams: leaked credentials, exposed instances, and backups that turn out not to work. The enterprise items solve different, rarer problems, and adopting them before there is a reason to usually means maintaining controls nobody is actually checking, which is its own kind of false security. Add them when a real requirement names them, not before.
The one exception worth adopting early regardless of team size is basic access logging, who connected, from where, and when, since it costs almost nothing to enable and is the difference between having an answer and having a guess the one time someone actually needs to know what happened to a database after the fact.
How this fits the rest of the stack
Most of this checklist is infrastructure a managed database should hand you rather than something you configure by hand. A RunxBuild Postgres or MySQL database sits on private networking by default, so it is not reachable from the public internet without you opening it up; user management in the dashboard creates a scoped user per application instead of sharing one root login; and backups run on a schedule you can actually restore from, not a folder you hope exists. Add a database to a project on the RunxBuild hosting calculator and see what it costs beside the app using it. Postgres’s own SSL connection documentation covers the client-side flags in full.
Useful related references:
- Connect to a Database Remotely Without Leaving It Open to the Internet
- Database Backup: Strategies That Survive a Real Disaster (Not Just a Test)
- create user postgres: The Commands That Work on Every Cluster and the Defaults That Bite You
- Databases on RunxBuild
FAQ
What is database security?
The set of practices that keep a database’s data from being read, modified or destroyed by anyone who should not have access. For a small team this is less about a formal framework and more about concrete steps: no public exposure, scoped users, encrypted connections, tested backups and secrets kept out of source control.
Why is database security important?
A database usually holds the data an incident actually costs you: customer records, credentials, billing information. A leaked application secret or a misconfigured firewall rule can expose all of it at once, and unlike a single compromised page, a compromised database is rarely a small, contained problem.
What are the most common database security mistakes?
A database reachable from the public internet with just a password, an application connecting as a shared root or superuser instead of a scoped user, credentials committed to a repository, and backups that exist but have never actually been restored to confirm they work.
Should every application have its own database user?
Yes. A shared root user means one compromised application’s credentials expose every other application on the same database. A scoped user with only the privileges that application needs limits the damage of any single credential leak to that one application’s tables.
How often should database backups be tested?
At minimum once when the backup schedule is first set up, by restoring into a scratch database and confirming the data is actually usable, and again after any significant schema change. A backup nobody has restored is unverified, regardless of how long the schedule has been running.