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:
- Humans deploy, services run
- One application = one service user
- One GitHub repository = one SSH key
- Secrets never live in Git
- 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
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
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
Example layout:
/var/apps/
├── app-one/
├── app-two/
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"
When prompted for file name:
/home/dev/.ssh/id_ed25519_app_one
Leave passphrase empty.
This creates:
~/.ssh/id_ed25519_app_one
~/.ssh/id_ed25519_app_one.pub
Step 5: Add the Deploy Key to GitHub
On GitHub:
- Open the repository
- Go to Settings → Deploy keys
- Add a new key
- Paste the contents of:
cat ~/.ssh/id_ed25519_app_one.pub
- 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
Add:
Host github.com-app-one
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_app_one
IdentitiesOnly yes
Now clone using the alias:
git clone git@github.com-app-one:org/repo.git app-one
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
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
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
Example:
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@127.0.0.1:5432/appdb
JWT_SECRET=long_random_secret
Secure it:
sudo chown root:root /etc/systemd/system/app-one.env
sudo chmod 600 /etc/systemd/system/app-one.env
Step 10: Create a Hardened systemd Service
Create the service file:
sudo nano /etc/systemd/system/app-one.service
[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
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable app-one
sudo systemctl start app-one
Step 11: Check Application Logs
systemd captures logs automatically.
journalctl -u app-one -f
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
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
🚫 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)