DEV Community

Cover image for How to Deploy Applications Securely on a Linux Server (A Production-Grade, Framework-Agnostic Guide)
Yousuf Basir
Yousuf Basir

Posted on

How to Deploy Applications Securely on a Linux Server (A Production-Grade, Framework-Agnostic Guide)

How to Deploy Applications Securely on a Linux Server

A Production-Grade, Step-by-Step Guide (Beginner Friendly)

Deploying an application is easy.
Deploying it securely, so that mistakes, framework quirks, or compromised code don’t turn into server breaches, requires a clear structure.

This article walks through a real-world, production-grade deployment workflow using standard Linux tools — no magic, no shortcuts.

It applies to:

  • backend apps (NestJS, Express, Fastify, etc.)
  • frontend / SSR apps (Next.js)
  • any Node.js-based application

The same ideas also apply beyond Node.js.


Core Principles (Why This Works)

Before touching commands, understand the rules:

  1. Humans deploy, services run
  2. One application = one service user
  3. One GitHub repository = one SSH key
  4. Secrets never live in Git
  5. The operating system enforces security, not the app

If something feels “inconvenient”, that friction is intentional.


Step 1: Create a Human Deployment User

Never deploy as root.

Create a normal user (example: dev):

sudo adduser dev
sudo usermod -aG sudo dev
Enter fullscreen mode Exit fullscreen mode

This user:

  • can SSH
  • can pull code
  • can build applications
  • does not run applications

From now on, log in as dev.


Step 2: Create a Service User (One Per Application)

Each application should run as its own locked-down system user.

sudo adduser \
  --system \
  --no-create-home \
  --group \
  --shell /usr/sbin/nologin \
  svc-myapp
Enter fullscreen mode Exit fullscreen mode

This user:

  • cannot SSH
  • has no shell
  • cannot use sudo
  • exists only to run the app

If one app is compromised, the damage stops there.


Step 3: Prepare a Clean Directory Structure

Create a central place for applications:

sudo mkdir -p /var/apps
sudo chmod 755 /var/apps
Enter fullscreen mode Exit fullscreen mode

Example layout:

/var/apps/
├── app-one/
├── app-two/
Enter fullscreen mode Exit fullscreen mode

Each app lives in its own directory.


Step 4: Generate a GitHub Deploy Key (Per Repository)

Never reuse SSH keys across repositories.

As the dev user:

ssh-keygen -t ed25519 -C "github-deploy-app-one"
Enter fullscreen mode Exit fullscreen mode

When prompted for file name:

/home/dev/.ssh/id_ed25519_app_one
Enter fullscreen mode Exit fullscreen mode

Leave passphrase empty.

This creates:

~/.ssh/id_ed25519_app_one
~/.ssh/id_ed25519_app_one.pub
Enter fullscreen mode Exit fullscreen mode

Step 5: Add the Deploy Key to GitHub

On GitHub:

  1. Open the repository
  2. Go to Settings → Deploy keys
  3. Add a new key
  4. Paste the contents of:
cat ~/.ssh/id_ed25519_app_one.pub
Enter fullscreen mode Exit fullscreen mode
  1. Grant read-only access

⚠️ Important
A deploy key can only belong to one repository. This is by design.


Step 6: Configure SSH Alias (Very Important)

When using multiple deploy keys, SSH aliases are mandatory.

Edit:

nano ~/.ssh/config
Enter fullscreen mode Exit fullscreen mode

Add:

Host github.com-app-one
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519_app_one
  IdentitiesOnly yes
Enter fullscreen mode Exit fullscreen mode

Now clone using the alias:

git clone git@github.com-app-one:org/repo.git app-one
Enter fullscreen mode Exit fullscreen mode

Without aliases, Git will use the wrong key.


Step 7: Clone and Build the App (as dev)

cd /var/apps
git clone git@github.com-app-one:org/repo.git
cd app-one

npm ci
npm run build
npm prune --production
Enter fullscreen mode Exit fullscreen mode

Why npm prune --production?

It removes:

  • build tools
  • linters
  • test frameworks

This reduces attack surface and memory usage.


Step 8: Switch Ownership to the Service User

Once the build is complete:

sudo chown -R svc-myapp:svc-myapp /var/apps/app-one
sudo chmod -R o-rwx /var/apps/app-one
Enter fullscreen mode Exit fullscreen mode

At this point:

  • the app is runtime-only
  • humans should not modify it

If dev now gets “permission denied”, that’s expected.


Step 9: Store Environment Variables Securely

Never use .env files inside the repository in production.

Create a system-managed env file:

sudo nano /etc/systemd/system/app-one.env
Enter fullscreen mode Exit fullscreen mode

Example:

NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@127.0.0.1:5432/appdb
JWT_SECRET=long_random_secret
Enter fullscreen mode Exit fullscreen mode

Secure it:

sudo chown root:root /etc/systemd/system/app-one.env
sudo chmod 600 /etc/systemd/system/app-one.env
Enter fullscreen mode Exit fullscreen mode

Step 10: Create a Hardened systemd Service

Create the service file:

sudo nano /etc/systemd/system/app-one.service
Enter fullscreen mode Exit fullscreen mode
[Unit]
Description=Application One
After=network.target

[Service]
Type=simple
User=svc-myapp
Group=svc-myapp
WorkingDirectory=/var/apps/app-one
EnvironmentFile=/etc/systemd/system/app-one.env

ExecStart=/usr/bin/node dist/main.js
Restart=always
RestartSec=3

# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/apps/app-one
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
LockPersonality=true
RestrictSUIDSGID=true
CapabilityBoundingSet=
AmbientCapabilities=
UMask=0077

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable app-one
sudo systemctl start app-one
Enter fullscreen mode Exit fullscreen mode

Step 11: Check Application Logs

systemd captures logs automatically.

journalctl -u app-one -f
Enter fullscreen mode Exit fullscreen mode

This replaces PM2 logs or manual log files.


Step 12: What’s Unique About Next.js

Most backend frameworks can bind to 127.0.0.1.

Next.js does not.

  • It always binds to 0.0.0.0
  • It ignores host-related env variables
  • This is expected behavior

This does not mean your app is exposed.


Step 13: Firewall Is the Real Security Boundary

Your firewall decides what is public.

Example (UFW):

sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
Enter fullscreen mode Exit fullscreen mode

Do not allow application ports like 3000, 8000, etc.

Result:

  • app is reachable internally
  • blocked externally
  • reverse proxy is the only entry point

Step 14: Updating the Application Later (Correct Workflow)

When deploying updates:

sudo chown -R dev:dev /var/apps/app-one

cd /var/apps/app-one
git pull
npm ci
npm run build
npm prune --production

sudo chown -R svc-myapp:svc-myapp /var/apps/app-one
sudo chmod -R o-rwx /var/apps/app-one

sudo systemctl restart app-one
Enter fullscreen mode Exit fullscreen mode

🚫 Never use sudo git pull.

Ownership switching is explicit and safe.


Final Checklist

Before calling it “done”:

  • [ ] One service user per app
  • [ ] One deploy key per repo
  • [ ] SSH aliases configured
  • [ ] Build done as human user
  • [ ] Runtime owned by service user
  • [ ] Secrets outside Git
  • [ ] systemd hardening enabled
  • [ ] Firewall blocks app ports
  • [ ] Logs available via journalctl

Closing Thoughts

Secure deployment isn’t about a specific framework.
It’s about clear boundaries, least privilege, and letting Linux do the hard work.

Once this structure is in place:

  • switching stacks becomes easy
  • mistakes are survivable
  • security becomes predictable

This is how small teams run production servers professionally.

Happy (and secure) deploying 🚀

Top comments (0)