<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: khimananda Oli</title>
    <description>The latest articles on Forem by khimananda Oli (@khimananda).</description>
    <link>https://forem.com/khimananda</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F686596%2Fe0db53ad-84da-4f7b-97b0-e6e5e483b99b.jpeg</url>
      <title>Forem: khimananda Oli</title>
      <link>https://forem.com/khimananda</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/khimananda"/>
    <language>en</language>
    <item>
      <title>Ditch Static IAM Keys: Run Terraform with AWS SSO</title>
      <dc:creator>khimananda Oli</dc:creator>
      <pubDate>Thu, 09 Apr 2026 05:08:30 +0000</pubDate>
      <link>https://forem.com/khimananda/ditch-static-iam-keys-run-terraform-with-aws-sso-49b5</link>
      <guid>https://forem.com/khimananda/ditch-static-iam-keys-run-terraform-with-aws-sso-49b5</guid>
      <description>&lt;p&gt;If your team is still using shared IAM user credentials to run Terraform, it's time to switch to AWS SSO (IAM Identity Center). In this article, I'll walk you through how I migrated our multi-account Terraform setup from a shared deployment IAM user to individual SSO-based authentication for both local development and CI/CD pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Previous Setup
&lt;/h2&gt;

&lt;p&gt;We had a classic multi-account Terraform setup with three AWS accounts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shared/management account&lt;/strong&gt; - Hosted the S3 state bucket, DynamoDB lock table, and custom Terraform modules in S3&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev account&lt;/strong&gt; - Development environment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live/prod account&lt;/strong&gt; - Production environment (with additional &lt;code&gt;live-eu&lt;/code&gt; and &lt;code&gt;live-dr&lt;/code&gt; workspaces)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single IAM user called &lt;code&gt;deployment&lt;/code&gt; lived in the shared account. It had an access key that was shared across the team and stored as GitHub secrets for CI/CD. The Terraform provider used &lt;code&gt;assume_role&lt;/code&gt; to switch into the target account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
  &lt;span class="nx"&gt;assume_role&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;role_arn&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:iam::&amp;lt;account_id&amp;gt;:role/deployment"&lt;/span&gt;
    &lt;span class="nx"&gt;session_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"deployment"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The S3 backend stored state and locks in the shared account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-tf-states"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"core.tfstate"&lt;/span&gt;
  &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-locks"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And our GitHub Actions workflow used static IAM keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;aws-access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-secret-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Custom modules were stored in an S3 bucket and referenced like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"my_module"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3::/my-tf-modules/1.0.41/my-module.zip"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This setup worked, but it had serious problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No individual accountability&lt;/strong&gt; - CloudTrail logs showed &lt;code&gt;deployment&lt;/code&gt; user for every change, making it impossible to trace who did what&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security risk&lt;/strong&gt; - Static keys can leak, get committed to git, or be shared insecurely&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key rotation pain&lt;/strong&gt; - Rotating one shared key means updating it everywhere&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No MFA enforcement&lt;/strong&gt; - Long-lived access keys bypass MFA requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Solution: AWS SSO + OIDC
&lt;/h2&gt;

&lt;p&gt;With AWS IAM Identity Center (SSO), each DevOps engineer authenticates with their own identity. For CI/CD, GitHub Actions uses OIDC federation - no static keys stored as secrets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Local Development:
  Engineer -&amp;gt; AWS SSO Login -&amp;gt; Temporary Credentials -&amp;gt; Terraform

CI/CD (GitHub Actions):
  GitHub Actions -&amp;gt; OIDC Token -&amp;gt; AWS STS -&amp;gt; Temporary Credentials -&amp;gt; Terraform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Remove the assume_role Block
&lt;/h2&gt;

&lt;p&gt;Since each engineer will authenticate directly via SSO into the target account, there's no need for &lt;code&gt;assume_role&lt;/code&gt;. Terraform will use whatever credentials are in the environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
  &lt;span class="nx"&gt;assume_role&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;role_arn&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:iam::&amp;lt;account_id&amp;gt;:role/deployment"&lt;/span&gt;
    &lt;span class="nx"&gt;session_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"deployment"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Fix the S3 Backend for Cross-Account State Access
&lt;/h2&gt;

&lt;p&gt;This was the trickiest part. Our state bucket and DynamoDB lock table lived in the shared account, but now we're authenticating directly into dev/live accounts via SSO.&lt;/p&gt;

&lt;p&gt;The problem: Terraform's S3 backend always looks for the DynamoDB lock table &lt;strong&gt;in the caller's account&lt;/strong&gt;. So when authenticated as dev account, it looks for the lock table in dev - not in the shared account where it actually exists.&lt;/p&gt;

&lt;p&gt;The fix: Add a &lt;code&gt;profile&lt;/code&gt; to the backend config pointing to the shared account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-tf-states"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"core.tfstate"&lt;/span&gt;
  &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-locks"&lt;/span&gt;
  &lt;span class="nx"&gt;profile&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"shared-account"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures both S3 and DynamoDB calls go to the shared account, while the provider uses your SSO profile for the target account.&lt;/p&gt;

&lt;p&gt;You'll also need an S3 bucket policy on the state bucket to allow cross-account access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TerraformStateAccess"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"AWS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::&amp;lt;dev_account_id&amp;gt;:root"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::&amp;lt;live_account_id&amp;gt;:root"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"s3:DeleteObject"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-tf-states"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-tf-states/*"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After changing the backend config, reinitialize:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init &lt;span class="nt"&gt;-reconfigure&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Set Up AWS SSO Profiles
&lt;/h2&gt;

&lt;p&gt;Each engineer adds profiles to their &lt;code&gt;~/.aws/config&lt;/code&gt; - one per account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# Dev account
&lt;/span&gt;&lt;span class="nn"&gt;[profile dev]&lt;/span&gt;
&lt;span class="py"&gt;sso_start_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://your-org.awsapps.com/start/#/&lt;/span&gt;
&lt;span class="py"&gt;sso_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;span class="py"&gt;sso_account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;dev_account_id&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;sso_role_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;SuperAdmin&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;

&lt;span class="c"&gt;# Production account
&lt;/span&gt;&lt;span class="nn"&gt;[profile prod]&lt;/span&gt;
&lt;span class="py"&gt;sso_start_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://your-org.awsapps.com/start/#/&lt;/span&gt;
&lt;span class="py"&gt;sso_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;span class="py"&gt;sso_account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;live_account_id&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;sso_role_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;SuperAdmin&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;

&lt;span class="c"&gt;# Shared account (for Terraform state backend)
&lt;/span&gt;&lt;span class="nn"&gt;[profile shared-account]&lt;/span&gt;
&lt;span class="py"&gt;sso_start_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://your-org.awsapps.com/start/#/&lt;/span&gt;
&lt;span class="py"&gt;sso_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;span class="py"&gt;sso_account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;shared_account_id&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;sso_role_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;SuperAdmin&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;SuperAdmin&lt;/code&gt; with your SSO permission set name.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you manage Linux servers alongside your Terraform infra, &lt;a href="https://linuxtools.app" rel="noopener noreferrer"&gt;LinuxTools.app&lt;/a&gt; is a handy CLI reference I use daily.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 4: Run Terraform Locally
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Login to SSO (opens browser for authentication)&lt;/span&gt;
aws sso login &lt;span class="nt"&gt;--profile&lt;/span&gt; dev
aws sso login &lt;span class="nt"&gt;--profile&lt;/span&gt; shared-account

&lt;span class="c"&gt;# IMPORTANT: Clear any old static credentials first&lt;/span&gt;
&lt;span class="nb"&gt;unset &lt;/span&gt;AWS_ACCESS_KEY_ID
&lt;span class="nb"&gt;unset &lt;/span&gt;AWS_SECRET_ACCESS_KEY
&lt;span class="nb"&gt;unset &lt;/span&gt;AWS_SESSION_TOKEN

&lt;span class="c"&gt;# Set the profile for the target account&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_PROFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="c"&gt;# Verify you're using the SSO role (not the old IAM user)&lt;/span&gt;
aws sts get-caller-identity
&lt;span class="c"&gt;# Should show: arn:aws:sts::&amp;lt;dev_account_id&amp;gt;:assumed-role/AWSReservedSSO_SuperAdmin_.../you@company.com&lt;/span&gt;

&lt;span class="c"&gt;# Run Terraform&lt;/span&gt;
terraform init
terraform workspace &lt;span class="k"&gt;select &lt;/span&gt;dev
terraform plan
terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; If &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; / &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; are set in your environment, they take precedence over &lt;code&gt;AWS_PROFILE&lt;/code&gt;. Always &lt;code&gt;unset&lt;/code&gt; them first. I spent a while debugging this - &lt;code&gt;aws sts get-caller-identity&lt;/code&gt; kept showing the old &lt;code&gt;user/deployment&lt;/code&gt; identity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 5: Set Up GitHub Actions with OIDC
&lt;/h2&gt;

&lt;p&gt;We already had an OIDC provider configured in AWS using the &lt;code&gt;unfunco/oidc-github/aws&lt;/code&gt; module. If you don't have one yet, add it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"oidc_github"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"unfunco/oidc-github/aws"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.8.0"&lt;/span&gt;

  &lt;span class="nx"&gt;github_repositories&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"your-org/your-terraform-repo"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;attach_admin_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"oidc_role_arn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oidc_github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iam_role_arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then update the GitHub Actions workflow:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (static keys):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;aws-access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
      &lt;span class="na"&gt;aws-secret-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
      &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (OIDC):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# Required for OIDC&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_OIDC_ROLE_ARN }}&lt;/span&gt;
      &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Added &lt;code&gt;id-token: write&lt;/code&gt; permission (required for GitHub to issue OIDC tokens)&lt;/li&gt;
&lt;li&gt;Replaced &lt;code&gt;aws-access-key-id&lt;/code&gt; / &lt;code&gt;aws-secret-access-key&lt;/code&gt; with &lt;code&gt;role-to-assume&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set &lt;code&gt;AWS_OIDC_ROLE_ARN&lt;/code&gt; per GitHub environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;development&lt;/strong&gt; environment: OIDC role ARN from your dev account&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;production&lt;/strong&gt; environment: OIDC role ARN from your prod account&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Upgrade AWS Provider and Modules
&lt;/h2&gt;

&lt;p&gt;After switching to SSO, I hit this error on &lt;code&gt;terraform plan&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An argument named "enable_classiclink" is not expected here.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This happened because we were on AWS provider &lt;code&gt;4.67&lt;/code&gt; and VPC module &lt;code&gt;3.18.1&lt;/code&gt;. EC2-Classic was fully retired by AWS, and these older versions still reference deprecated &lt;code&gt;classiclink&lt;/code&gt; attributes.&lt;/p&gt;

&lt;p&gt;The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Provider: 4.67 -&amp;gt; 5.x&lt;/span&gt;
&lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;aws&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/aws"&lt;/span&gt;
    &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 5.0"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# VPC module: 3.18.1 -&amp;gt; 5.16.0&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"vpc"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-aws-modules/vpc/aws"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"5.16.0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init &lt;span class="nt"&gt;-upgrade&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 7: Migrate Module Sources from S3 to GitHub
&lt;/h2&gt;

&lt;p&gt;We had custom Terraform modules stored in an S3 bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"my_module"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3::/my-tf-modules/1.0.41/my-module.zip"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After switching to SSO, &lt;code&gt;terraform init&lt;/code&gt; failed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NoCredentialProviders: no valid providers in chain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root cause: Terraform's module downloader uses the Go AWS SDK internally, which does not respect &lt;code&gt;AWS_PROFILE&lt;/code&gt; when downloading S3 sources. The cleanest fix was switching to GitHub as the module source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"my_module"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"github.com/your-org/your-tf-modules//my-module?ref=v1.0.93"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Old credentials overriding SSO
&lt;/h3&gt;

&lt;p&gt;Environment variables take precedence over &lt;code&gt;AWS_PROFILE&lt;/code&gt;. Always clear them first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;unset &lt;/span&gt;AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. DynamoDB lock table not found in the right account
&lt;/h3&gt;

&lt;p&gt;Without &lt;code&gt;profile&lt;/code&gt; in the backend config, you'll get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AccessDeniedException: User is not authorized to perform: dynamodb:PutItem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. S3 module downloads failing with NoCredentialProviders
&lt;/h3&gt;

&lt;p&gt;Switch to GitHub-hosted modules or export credentials before &lt;code&gt;terraform init&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws configure export-credentials &lt;span class="nt"&gt;--profile&lt;/span&gt; dev &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
terraform init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Missing id-token: write permission in GitHub Actions
&lt;/h3&gt;

&lt;p&gt;OIDC requires &lt;code&gt;id-token: write&lt;/code&gt; permission. Without it, the OIDC token request fails silently.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Backend configuration changed error
&lt;/h3&gt;

&lt;p&gt;After adding &lt;code&gt;profile&lt;/code&gt; to the backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Backend configuration changed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform init -reconfigure&lt;/code&gt; to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local auth&lt;/td&gt;
&lt;td&gt;Shared &lt;code&gt;deployment&lt;/code&gt; IAM user + static keys&lt;/td&gt;
&lt;td&gt;Individual SSO login per engineer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD auth&lt;/td&gt;
&lt;td&gt;Static keys in GitHub secrets&lt;/td&gt;
&lt;td&gt;OIDC federation (no secrets needed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider config&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;assume_role&lt;/code&gt; to &lt;code&gt;deployment&lt;/code&gt; role&lt;/td&gt;
&lt;td&gt;No assume_role (uses env credentials)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State backend&lt;/td&gt;
&lt;td&gt;Direct access from shared account&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;profile&lt;/code&gt; pointing to shared account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit trail&lt;/td&gt;
&lt;td&gt;"deployment" user for everyone&lt;/td&gt;
&lt;td&gt;Individual engineer identity in CloudTrail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key rotation&lt;/td&gt;
&lt;td&gt;Manual, painful, shared&lt;/td&gt;
&lt;td&gt;Automatic, per-session, no keys to manage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Module sources&lt;/td&gt;
&lt;td&gt;S3 bucket (breaks with SSO)&lt;/td&gt;
&lt;td&gt;GitHub repo (uses git credentials)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS provider&lt;/td&gt;
&lt;td&gt;4.67&lt;/td&gt;
&lt;td&gt;~&amp;gt; 5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC module&lt;/td&gt;
&lt;td&gt;3.18.1&lt;/td&gt;
&lt;td&gt;5.16.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The migration took some troubleshooting, but the security benefits are significant. Every &lt;code&gt;terraform apply&lt;/code&gt; is now traceable to an individual engineer in CloudTrail, credentials are short-lived and automatically rotated, and there are no static keys to leak or manage.&lt;/p&gt;

&lt;p&gt;If you are working with Linux servers as part of your infrastructure, check out &lt;a href="https://linuxtools.app" rel="noopener noreferrer"&gt;LinuxTools.app&lt;/a&gt; - a free reference for CLI commands and utilities I use daily.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by &lt;a href="https://khimananda.com" rel="noopener noreferrer"&gt;Khimananda Oli&lt;/a&gt;. Find more DevOps and cloud infrastructure content at &lt;a href="https://khimananda.com" rel="noopener noreferrer"&gt;khimananda.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>I Built 30 Free Browser-Based Linux &amp; DevOps Tools — Here's What I Learned</title>
      <dc:creator>khimananda Oli</dc:creator>
      <pubDate>Mon, 23 Mar 2026 07:15:53 +0000</pubDate>
      <link>https://forem.com/khimananda/i-built-30-free-browser-based-linux-devops-tools-heres-what-i-learned-97a</link>
      <guid>https://forem.com/khimananda/i-built-30-free-browser-based-linux-devops-tools-heres-what-i-learned-97a</guid>
      <description>&lt;p&gt;Every time I needed to calculate a subnet, generate a cron expression, or decode a JWT, I'd end up on some ad-infested site that wanted me to sign up, install an extension, or — worst of all — send my data to their server.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://linuxtools.app" rel="noopener noreferrer"&gt;LinuxTools.app&lt;/a&gt; — &lt;strong&gt;30 free tools&lt;/strong&gt; that run entirely in your browser. No sign-up. No tracking. No data leaves your machine.&lt;/p&gt;

&lt;p&gt;Here's what I built and what I learned along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Inside
&lt;/h2&gt;

&lt;p&gt;The tools are organized into three categories:&lt;/p&gt;

&lt;h3&gt;
  
  
  🐧 Linux Tools (15)
&lt;/h3&gt;

&lt;p&gt;These are the ones I use daily as a sysadmin:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chmod Calculator&lt;/strong&gt; — Visual permission calculator. Toggle rwx checkboxes, see the numeric value update in real-time. Way faster than doing the math in your head.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron Expression Generator&lt;/strong&gt; — Build cron jobs visually with a preview of the next 5 execution times. No more guessing if &lt;code&gt;*/5 * * * *&lt;/code&gt; means every 5 minutes or every 5th minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH Config Generator&lt;/strong&gt; — Build &lt;code&gt;~/.ssh/config&lt;/code&gt; visually. Add hosts, keys, tunnels, and proxy jumps without remembering the syntax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx Config Generator&lt;/strong&gt; — Generate configs for reverse proxy, SSL termination, redirects, and more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Systemd Unit File Builder&lt;/strong&gt; — Create service files visually instead of copy-pasting from StackOverflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UFW / iptables Rule Generator&lt;/strong&gt; — Build firewall rules without memorizing flags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server Hardening Checklist&lt;/strong&gt; — Step-by-step security checklist with actual commands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus: File Permission Converter, Linux Command Cheat Sheets, Shell Script Library, Vim/Nano shortcuts, Package Manager Comparison, Disk Usage Visualizer, Process Explorer, and Performance Analyzer.&lt;/p&gt;

&lt;h3&gt;
  
  
  ☁️ Cloud &amp;amp; DevOps (5)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subnet Calculator&lt;/strong&gt; — CIDR notation, network/broadcast addresses, host ranges&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dockerfile Generator&lt;/strong&gt; — Pick base image, add packages, expose ports, generate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes YAML Generator&lt;/strong&gt; — Deployments, services, ingress configs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform Snippet Generator&lt;/strong&gt; — AWS/GCP/Azure resource templates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Cost Calculator&lt;/strong&gt; — Estimate costs across providers&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🔧 Utilities (9)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Base64 Encoder/Decoder&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JSON/YAML/TOML Converter&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regex Tester&lt;/strong&gt; — Real-time match highlighting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git Command Builder&lt;/strong&gt; — Pick what you want to do, get the command&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSL Certificate Checker&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UUID Generator&lt;/strong&gt; — v1/v3/v4/v5 with bulk generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON to .env Converter&lt;/strong&gt; — AWS Secrets Manager / Vault JSON → dotenv&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT Encoder/Decoder&lt;/strong&gt; — Inspect headers, payloads, verify HMAC signatures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network Tools&lt;/strong&gt; — DNS lookup, WHOIS, reverse DNS, HTTP headers, and more&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🛡️ NetOps — Network Reconnaissance
&lt;/h3&gt;

&lt;p&gt;I also built a &lt;a href="https://linuxtools.app/netops" rel="noopener noreferrer"&gt;NetOps&lt;/a&gt; section with a self-hosted API backend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Traceroute, Ping, MTR&lt;/li&gt;
&lt;li&gt;DNS Lookup, Reverse DNS&lt;/li&gt;
&lt;li&gt;WHOIS, GeoIP, ASN Lookup&lt;/li&gt;
&lt;li&gt;Nmap scan, HTTP Headers, Page Links&lt;/li&gt;
&lt;li&gt;Subnet Calculator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All powered by a &lt;strong&gt;FastAPI backend in Docker&lt;/strong&gt; with local MaxMind GeoIP databases — zero external API dependencies.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tech Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frontend:  Next.js 15 (static export) + TypeScript + Tailwind CSS
Backend:   Python FastAPI (Docker) — for network tools only
Fonts:     Inter (body) + JetBrains Mono (code) + Bebas Neue (display)
Hosting:   Self-hosted on a VPS behind Cloudflare
Deploy:    rsync + Docker Compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why Static Export?
&lt;/h3&gt;

&lt;p&gt;Most tools are pure client-side JavaScript. No server needed for chmod calculations or cron parsing. Static export means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fast&lt;/strong&gt; — HTML served directly by nginx&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap&lt;/strong&gt; — No server-side rendering costs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private&lt;/strong&gt; — Data never leaves the browser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliable&lt;/strong&gt; — No backend to go down&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only exception is the NetOps tools (ping, traceroute, nmap) which obviously need a server. Those run in a Docker container with a FastAPI backend.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Client-side tools are underrated
&lt;/h3&gt;

&lt;p&gt;Most "online tools" unnecessarily send your data to a server. A chmod calculator doesn't need a backend. A JWT decoder doesn't need your token sent over the wire. Running everything client-side is faster, more private, and simpler to deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The hardest part is the UI, not the logic
&lt;/h3&gt;

&lt;p&gt;Calculating file permissions is trivial. Making a UI that's intuitive enough that someone can figure it out in 3 seconds — that's the actual challenge.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. SEO matters for tools
&lt;/h3&gt;

&lt;p&gt;People find tools through Google. Every tool page has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Proper &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;meta description&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Canonical URLs&lt;/li&gt;
&lt;li&gt;Hidden &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; with keyword-rich text&lt;/li&gt;
&lt;li&gt;Structured sitemap&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Dark theme or nothing
&lt;/h3&gt;

&lt;p&gt;Developers live in dark mode. I didn't even bother with a light theme toggle. GitHub-dark inspired palette with soft blue accents.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Self-hosting network tools requires security
&lt;/h3&gt;

&lt;p&gt;When I added nmap and traceroute to the API, I had to think about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; — 5 nmap scans per 5 minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal IP blocking&lt;/strong&gt; — Can't scan 192.168.x.x or 10.x.x.x&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API key auth&lt;/strong&gt; — Injected by nginx, never reaches the browser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input validation&lt;/strong&gt; — Strict regex on all inputs&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Some features on the roadmap based on user feedback:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cmd+K search&lt;/strong&gt; — Global keyboard shortcut to find tools instantly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin/favorite tools&lt;/strong&gt; — Quick access to your most-used tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form state persistence&lt;/strong&gt; — So you don't lose data on accidental refresh&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More pentest tools&lt;/strong&gt; — Building on the FastAPI backend&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It Out
&lt;/h2&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://linuxtools.app" rel="noopener noreferrer"&gt;linuxtools.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All tools are free. No sign-up. No ads (ok, there's AdSense, but no paywalls).&lt;/p&gt;

&lt;p&gt;If you find it useful, I'd love to hear which tools you use the most — or what tools you wish existed.&lt;/p&gt;

&lt;p&gt;Drop a comment below or reach out on the &lt;a href="https://linuxtools.app/contact" rel="noopener noreferrer"&gt;contact page&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with ☕ and too many terminal sessions.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Reverse Proxy for Node.js — Nginx and Apache2 Side by Side</title>
      <dc:creator>khimananda Oli</dc:creator>
      <pubDate>Tue, 17 Mar 2026 17:59:37 +0000</pubDate>
      <link>https://forem.com/khimananda/reverse-proxy-for-nodejs-nginx-and-apache2-side-by-side-4ok0</link>
      <guid>https://forem.com/khimananda/reverse-proxy-for-nodejs-nginx-and-apache2-side-by-side-4ok0</guid>
      <description>&lt;p&gt;&lt;em&gt;In &lt;a href="https://dev.to/khimananda"&gt;Part 1&lt;/a&gt;, we set up NVM and PM2. In &lt;a href="https://dev.to/khimananda"&gt;Part 2&lt;/a&gt;, we started the Node.js application. Now let's put a reverse proxy in front of it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Reverse Proxy?
&lt;/h2&gt;

&lt;p&gt;Your Node.js app should never be directly exposed on port 80 or 443. A reverse proxy handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSL/TLS termination&lt;/strong&gt; — Node doesn't deal with certificates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security headers&lt;/strong&gt; — added at the proxy layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static file serving&lt;/strong&gt; — offload from Node&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request buffering&lt;/strong&gt; — protects Node from slow clients&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Centralized access logs&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both Nginx and Apache2 get the job done. Pick whichever is already in your stack.&lt;/p&gt;




&lt;h2&gt;
  
  
  Nginx
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/nginx/sites-available/myapp.conf&lt;/span&gt;

&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;node_backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;myapp.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://&lt;/span&gt;&lt;span class="nv"&gt;$host$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;myapp.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# SSL&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/letsencrypt/live/myapp.example.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/myapp.example.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Security headers&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="s"&gt;"SAMEORIGIN"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-XSS-Protection&lt;/span&gt; &lt;span class="s"&gt;"1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;mode=block"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Strict-Transport-Security&lt;/span&gt; &lt;span class="s"&gt;"max-age=31536000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;includeSubDomains"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="s"&gt;"strict-origin-when-cross-origin"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# Proxy&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://node_backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# WebSocket support&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;'upgrade'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache_bypass&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# Pass client info&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;# Timeouts&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_connect_timeout&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Static files — serve directly, skip Node&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;/opt/myapp/public/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;30d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Health check — suppress access logs&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/health&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://node_backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Enable and Test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Apache2
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install and Enable Modules
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; apache2
&lt;span class="nb"&gt;sudo &lt;/span&gt;a2enmod proxy proxy_http proxy_wstunnel rewrite ssl headers
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/apache2/sites-available/myapp.conf&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;VirtualHost&lt;/span&gt;&lt;span class="sr"&gt; *:80&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;ServerName&lt;/span&gt; myapp.example.com

    &lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
    &lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTPS} &lt;span class="ss"&gt;off&lt;/span&gt;
    &lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;VirtualHost&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;VirtualHost&lt;/span&gt;&lt;span class="sr"&gt; *:443&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;ServerName&lt;/span&gt; myapp.example.com

    &lt;span class="nc"&gt;SSLEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
    &lt;span class="nc"&gt;SSLCertificateFile&lt;/span&gt;    /etc/letsencrypt/live/myapp.example.com/fullchain.pem
    &lt;span class="nc"&gt;SSLCertificateKeyFile&lt;/span&gt; /etc/letsencrypt/live/myapp.example.com/privkey.pem

    &lt;span class="c"&gt;# Security headers&lt;/span&gt;
    &lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;always&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; X-Frame-Options "SAMEORIGIN"
    &lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;always&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; X-Content-Type-Options "nosniff"
    &lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;always&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; X-XSS-Protection "1; mode=block"
    &lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;always&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; Strict-Transport-Security "max-age=31536000; includeSubDomains"
    &lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;always&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; Referrer-Policy "strict-origin-when-cross-origin"

    &lt;span class="c"&gt;# Proxy to Node.js&lt;/span&gt;
    &lt;span class="nc"&gt;ProxyPreserveHost&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
    &lt;span class="nc"&gt;ProxyPass&lt;/span&gt; / http://127.0.0.1:3000/
    &lt;span class="nc"&gt;ProxyPassReverse&lt;/span&gt; / http://127.0.0.1:3000/

    &lt;span class="c"&gt;# WebSocket support&lt;/span&gt;
    &lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
    &lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP:Upgrade} =websocket [NC]
    &lt;span class="nc"&gt;RewriteRule&lt;/span&gt; /(.*) ws://127.0.0.1:3000/$1 [P,L]

    &lt;span class="c"&gt;# Pass real client IP&lt;/span&gt;
    &lt;span class="nc"&gt;RequestHeader&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; X-Real-IP "%{REMOTE_ADDR}s"
    &lt;span class="nc"&gt;RequestHeader&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; X-Forwarded-Proto "https"

    &lt;span class="c"&gt;# Timeout&lt;/span&gt;
    &lt;span class="nc"&gt;ProxyTimeout&lt;/span&gt; 60

    &lt;span class="c"&gt;# Static files&lt;/span&gt;
    &lt;span class="nc"&gt;Alias&lt;/span&gt; /static /opt/myapp/public
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;Directory&lt;/span&gt;&lt;span class="sr"&gt; /opt/myapp/public&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;        &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; granted
        &lt;span class="nc"&gt;Options&lt;/span&gt; -Indexes
        &lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; Cache-Control "public, max-age=2592000, immutable"
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;Directory&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;
    &lt;span class="c"&gt;# Logging&lt;/span&gt;
    &lt;span class="nc"&gt;ErrorLog&lt;/span&gt; ${APACHE_LOG_DIR}/myapp-error.log
    &lt;span class="nc"&gt;CustomLog&lt;/span&gt; ${APACHE_LOG_DIR}/myapp-access.log combined
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;VirtualHost&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Enable and Test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;a2ensite myapp.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;a2dissite 000-default.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;apache2ctl configtest
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  SSL with Let's Encrypt
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Nginx&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; certbot python3-certbot-nginx
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; myapp.example.com

&lt;span class="c"&gt;# Apache2&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; certbot python3-certbot-apache
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--apache&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; myapp.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify auto-renewal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot renew &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already have certs (corporate CA, wildcard), just point the config to your cert and key paths.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trust the Proxy in Your Node App
&lt;/h2&gt;

&lt;p&gt;Your app needs to know it's behind a proxy to read the real client IP from &lt;code&gt;X-Forwarded-For&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Express&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;trust proxy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, &lt;code&gt;req.ip&lt;/code&gt; will always return &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Nginx vs Apache2 — Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Nginx&lt;/th&gt;
&lt;th&gt;Apache2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Architecture&lt;/td&gt;
&lt;td&gt;Event-driven, non-blocking&lt;/td&gt;
&lt;td&gt;Process/thread per connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;Low footprint&lt;/td&gt;
&lt;td&gt;Higher under load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static files&lt;/td&gt;
&lt;td&gt;Extremely fast&lt;/td&gt;
&lt;td&gt;Good, not as fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket&lt;/td&gt;
&lt;td&gt;Native support&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;mod_proxy_wstunnel&lt;/code&gt; + rewrite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;mod_ssl&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load balancing&lt;/td&gt;
&lt;td&gt;Built-in &lt;code&gt;upstream&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;mod_proxy_balancer&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.htaccess&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config reload&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nginx -t &amp;amp;&amp;amp; systemctl reload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apache2ctl configtest &amp;amp;&amp;amp; systemctl reload&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;New setups, high concurrency&lt;/td&gt;
&lt;td&gt;Legacy stacks, PHP apps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Bottom line:&lt;/strong&gt; Starting fresh → Nginx. Apache2 already in your stack → stick with it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verify the Full Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# PM2 running?&lt;/span&gt;
pm2 status

&lt;span class="c"&gt;# Port listening?&lt;/span&gt;
ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;3000

&lt;span class="c"&gt;# Reverse proxy responding?&lt;/span&gt;
curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://myapp.example.com

&lt;span class="c"&gt;# SSL valid?&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; myapp.example.com:443 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Verify return code'&lt;/span&gt;

&lt;span class="c"&gt;# Proxy logs&lt;/span&gt;
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/nginx/access.log          &lt;span class="c"&gt;# Nginx&lt;/span&gt;
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/apache2/myapp-access.log   &lt;span class="c"&gt;# Apache2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Full Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client (HTTPS:443)
    │
    ▼
┌──────────────────────┐
│  Nginx / Apache2     │  ← SSL, headers, static files
│  (port 80/443)       │
└──────────┬───────────┘
           │ proxy_pass http://127.0.0.1:3000
           ▼
┌──────────────────────┐
│  PM2 (cluster mode)  │  ← Process management, restarts
├──────────────────────┤
│  Node.js app         │  ← server.js / app.js
│  (port 3000)         │
└──────────────────────┘
           │
     NVM-managed Node binary
     ~/.nvm/versions/node/v20.18.0/bin/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Series Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/khimananda"&gt;Part 1&lt;/a&gt;&lt;/strong&gt; — NVM, PM2, startup scripts, log rotation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/khimananda"&gt;Part 2&lt;/a&gt;&lt;/strong&gt; — Running the app, cluster mode, memory limits, monitoring&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3&lt;/strong&gt; — Reverse proxy (Nginx + Apache2), SSL, security headers, verification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This stack is simple, debuggable, and production-proven. No containers, no orchestration overhead — just a Node app running reliably behind a proper proxy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Connect with me on &lt;a href="https://www.linkedin.com/in/khimananda" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or check out more DevOps content on &lt;a href="https://khimananda.com" rel="noopener noreferrer"&gt;khimananda.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>nginx</category>
      <category>apache</category>
      <category>devops</category>
    </item>
    <item>
      <title>Setting Up NVM, PM2, and Process Management on Linux</title>
      <dc:creator>khimananda Oli</dc:creator>
      <pubDate>Tue, 17 Mar 2026 16:50:05 +0000</pubDate>
      <link>https://forem.com/khimananda/setting-up-nvm-pm2-and-process-management-on-linux-25c7</link>
      <guid>https://forem.com/khimananda/setting-up-nvm-pm2-and-process-management-on-linux-25c7</guid>
      <description>&lt;h2&gt;
  
  
  Why NVM Over System Node?
&lt;/h2&gt;

&lt;p&gt;Don't &lt;code&gt;apt install nodejs&lt;/code&gt;. You'll get a stale version pinned to your distro's release cycle, and switching between projects that need different Node versions becomes a mess.&lt;/p&gt;

&lt;p&gt;NVM gives you per-user Node version management with zero system-level conflicts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Install NVM
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-o-&lt;/span&gt; https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload your shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nvm &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# 0.40.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Install Node.js
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install LTS&lt;/span&gt;
nvm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--lts&lt;/span&gt;

&lt;span class="c"&gt;# Or a specific version&lt;/span&gt;
nvm &lt;span class="nb"&gt;install &lt;/span&gt;20.18.0

&lt;span class="c"&gt;# Set default — without this, new shells may pick a different version&lt;/span&gt;
nvm &lt;span class="nb"&gt;alias &lt;/span&gt;default 20.18.0

&lt;span class="c"&gt;# Verify&lt;/span&gt;
node &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Where Does Node Live?
&lt;/h3&gt;

&lt;p&gt;NVM installs per-user. Your Node binary path looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/home/deploy/.nvm/versions/node/v20.18.0/bin/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters when PM2 needs to register a systemd service. Keep it in mind.&lt;/p&gt;




&lt;h2&gt;
  
  
  Install PM2
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; pm2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since we used NVM, PM2 lives at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.nvm/versions/node/v20.18.0/bin/pm2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick sanity check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  PM2 Startup — Survive Reboots
&lt;/h2&gt;

&lt;p&gt;This is where NVM + PM2 gets tricky. PM2's startup script needs the full path to the Node binary because systemd runs non-interactively — NVM isn't loaded in that context.&lt;/p&gt;

&lt;p&gt;Generate the startup script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 startup systemd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PM2 will print a command like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo env &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;:/home/deploy/.nvm/versions/node/v20.18.0/bin &lt;span class="se"&gt;\&lt;/span&gt;
  /home/deploy/.nvm/versions/node/v20.18.0/lib/node_modules/pm2/bin/pm2 &lt;span class="se"&gt;\&lt;/span&gt;
  startup systemd &lt;span class="nt"&gt;-u&lt;/span&gt; deploy &lt;span class="nt"&gt;--hp&lt;/span&gt; /home/deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Copy and run that exact command.&lt;/strong&gt; Don't modify it.&lt;/p&gt;

&lt;p&gt;Then save the current process list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PM2 will now resurrect all managed apps on reboot.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Stale &lt;code&gt;dump.pm2&lt;/code&gt; Trap
&lt;/h3&gt;

&lt;p&gt;If PM2 keeps resurrecting old or dead processes after a restart, it's because &lt;code&gt;pm2 save&lt;/code&gt; wrote a &lt;code&gt;dump.pm2&lt;/code&gt; file with stale state.&lt;/p&gt;

&lt;p&gt;Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 &lt;span class="nb"&gt;kill
rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.pm2/dump.pm2
&lt;span class="c"&gt;# start your apps again (covered in Part 2)&lt;/span&gt;
pm2 save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Always run &lt;code&gt;pm2 save&lt;/code&gt; after any change to your process list.&lt;/strong&gt; Forgetting this will bite you at 3 AM when the server reboots and your app doesn't come back.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  PM2 Log Rotation
&lt;/h2&gt;

&lt;p&gt;By default, PM2 logs grow unbounded. In production, that's a disk space incident waiting to happen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: pm2-logrotate (Recommended)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 &lt;span class="nb"&gt;install &lt;/span&gt;pm2-logrotate

pm2 &lt;span class="nb"&gt;set &lt;/span&gt;pm2-logrotate:max_size 50M
pm2 &lt;span class="nb"&gt;set &lt;/span&gt;pm2-logrotate:retain 10
pm2 &lt;span class="nb"&gt;set &lt;/span&gt;pm2-logrotate:compress &lt;span class="nb"&gt;true
&lt;/span&gt;pm2 &lt;span class="nb"&gt;set &lt;/span&gt;pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
pm2 &lt;span class="nb"&gt;set &lt;/span&gt;pm2-logrotate:workerInterval 30
pm2 &lt;span class="nb"&gt;set &lt;/span&gt;pm2-logrotate:rotateInterval &lt;span class="s1"&gt;'0 0 * * *'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 conf pm2-logrotate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option B: System logrotate
&lt;/h3&gt;

&lt;p&gt;If you prefer the OS-level approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/logrotate.d/pm2-myapp
&lt;/span&gt;/&lt;span class="n"&gt;home&lt;/span&gt;/&lt;span class="n"&gt;deploy&lt;/span&gt;/.&lt;span class="n"&gt;pm2&lt;/span&gt;/&lt;span class="n"&gt;logs&lt;/span&gt;/*.&lt;span class="n"&gt;log&lt;/span&gt; {
    &lt;span class="n"&gt;daily&lt;/span&gt;
    &lt;span class="n"&gt;missingok&lt;/span&gt;
    &lt;span class="n"&gt;rotate&lt;/span&gt; &lt;span class="m"&gt;14&lt;/span&gt;
    &lt;span class="n"&gt;compress&lt;/span&gt;
    &lt;span class="n"&gt;delaycompress&lt;/span&gt;
    &lt;span class="n"&gt;notifempty&lt;/span&gt;
    &lt;span class="n"&gt;copytruncate&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;copytruncate&lt;/code&gt; — PM2 holds the file handle open, so you can't move the log file out from under it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Reference — PM2 Commands
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 status              &lt;span class="c"&gt;# list all managed processes&lt;/span&gt;
pm2 logs                &lt;span class="c"&gt;# tail all logs&lt;/span&gt;
pm2 logs myapp          &lt;span class="c"&gt;# tail specific app&lt;/span&gt;
pm2 monit               &lt;span class="c"&gt;# real-time CPU/memory dashboard&lt;/span&gt;
pm2 reload myapp        &lt;span class="c"&gt;# zero-downtime reload (cluster mode)&lt;/span&gt;
pm2 restart myapp       &lt;span class="c"&gt;# hard restart&lt;/span&gt;
pm2 stop myapp          &lt;span class="c"&gt;# stop without removing&lt;/span&gt;
pm2 delete myapp        &lt;span class="c"&gt;# stop and remove from list&lt;/span&gt;
pm2 save                &lt;span class="c"&gt;# persist process list&lt;/span&gt;
pm2 resurrect           &lt;span class="c"&gt;# restore saved list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What We Have So Far
&lt;/h2&gt;

&lt;p&gt;At this point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ NVM installed with a pinned Node.js version&lt;/li&gt;
&lt;li&gt;✅ PM2 installed globally via NVM&lt;/li&gt;
&lt;li&gt;✅ Startup script registered — apps survive reboots&lt;/li&gt;
&lt;li&gt;✅ Log rotation configured — no disk space surprises&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://dev.to/khimananda/running-your-nodejs-application-with-pm2-82m" class="crayons-btn crayons-btn--primary"&gt;Follow for Part 2 — Running Your Node.js Application with PM2&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>node</category>
      <category>devops</category>
      <category>linux</category>
      <category>pm2</category>
    </item>
    <item>
      <title>Running Your Node.js Application with PM2</title>
      <dc:creator>khimananda Oli</dc:creator>
      <pubDate>Tue, 17 Mar 2026 16:46:29 +0000</pubDate>
      <link>https://forem.com/khimananda/running-your-nodejs-application-with-pm2-82m</link>
      <guid>https://forem.com/khimananda/running-your-nodejs-application-with-pm2-82m</guid>
      <description>&lt;p&gt;&lt;em&gt;In &lt;a href="https://dev.to/khimananda"&gt;Part 1&lt;/a&gt;, we installed NVM, PM2, configured log rotation, and set up startup scripts. Now let's run the actual application.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NVM, Node.js, and PM2 installed (Part 1)&lt;/li&gt;
&lt;li&gt;A Node.js application with a &lt;code&gt;server.js&lt;/code&gt; or &lt;code&gt;app.js&lt;/code&gt; entry point&lt;/li&gt;
&lt;li&gt;Dependencies installed (&lt;code&gt;npm install&lt;/code&gt; or &lt;code&gt;npm ci&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Start the Application
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Single Instance
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/myapp
pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. PM2 is managing the process.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────┬──────┬───────┬───┬─────┬──────────┐
│ id │ name │ mode  │ ↺ │ cpu │ memory   │
├────┼──────┼───────┼───┼─────┼──────────┤
│ 0  │ myapp│ fork  │ 0 │ 0%  │ 45.2 MB  │
└────┴──────┴───────┴───┴─────┴──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cluster Mode — Use All CPU Cores
&lt;/h3&gt;

&lt;p&gt;If your app is stateless (no in-memory sessions), cluster mode spawns one process per CPU core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; myapp &lt;span class="nt"&gt;-i&lt;/span&gt; max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────┬──────┬─────────┬───┬─────┬──────────┐
│ id │ name │ mode    │ ↺ │ cpu │ memory   │
├────┼──────┼─────────┼───┼─────┼──────────┤
│ 0  │ myapp│ cluster │ 0 │ 0%  │ 42.1 MB  │
│ 1  │ myapp│ cluster │ 0 │ 0%  │ 41.8 MB  │
│ 2  │ myapp│ cluster │ 0 │ 0%  │ 42.3 MB  │
│ 3  │ myapp│ cluster │ 0 │ 0%  │ 41.5 MB  │
└────┴──────┴─────────┴───┴─────┴──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or specify a fixed count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; myapp &lt;span class="nt"&gt;-i&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  With Environment Variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3000 &lt;span class="nv"&gt;NODE_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; myapp &lt;span class="nt"&gt;-i&lt;/span&gt; max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or pass them explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; myapp &lt;span class="nt"&gt;--env&lt;/span&gt; production &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--max-old-space-size=512"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--port&lt;/span&gt; 3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  With a &lt;code&gt;.env&lt;/code&gt; File
&lt;/h3&gt;

&lt;p&gt;If your app uses &lt;code&gt;dotenv&lt;/code&gt;, just make sure the &lt;code&gt;.env&lt;/code&gt; file is in the app's working directory. PM2 runs from the directory where you execute the start command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/myapp
&lt;span class="c"&gt;# .env file sits here alongside server.js&lt;/span&gt;
pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Memory Limit — Auto Restart on OOM
&lt;/h2&gt;

&lt;p&gt;Set a memory ceiling so a leaky app doesn't eat the whole server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; myapp &lt;span class="nt"&gt;--max-memory-restart&lt;/span&gt; 512M
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When any instance exceeds 512MB, PM2 restarts it automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Watching for File Changes (Dev/Staging Only)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; myapp &lt;span class="nt"&gt;--watch&lt;/span&gt; &lt;span class="nt"&gt;--ignore-watch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"node_modules logs"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't use &lt;code&gt;--watch&lt;/code&gt; in production.&lt;/strong&gt; A deployment that touches multiple files triggers multiple restarts. Use &lt;code&gt;pm2 reload&lt;/code&gt; instead.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Zero-Downtime Reload
&lt;/h2&gt;

&lt;p&gt;In cluster mode, &lt;code&gt;reload&lt;/code&gt; restarts workers one at a time — no dropped requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 reload myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In fork mode (single instance), &lt;code&gt;reload&lt;/code&gt; behaves like &lt;code&gt;restart&lt;/code&gt; — there will be a brief downtime.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verify the Application
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# PM2 status&lt;/span&gt;
pm2 status

&lt;span class="c"&gt;# Port listening?&lt;/span&gt;
ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;3000

&lt;span class="c"&gt;# Hit the app&lt;/span&gt;
curl http://localhost:3000

&lt;span class="c"&gt;# Logs&lt;/span&gt;
pm2 logs myapp &lt;span class="nt"&gt;--lines&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Save the Process List
&lt;/h2&gt;

&lt;p&gt;After starting your app, &lt;strong&gt;always save&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This writes the current process list to &lt;code&gt;~/.pm2/dump.pm2&lt;/code&gt;. On reboot, the startup script from Part 1 calls &lt;code&gt;pm2 resurrect&lt;/code&gt; and restores everything.&lt;/p&gt;

&lt;p&gt;Skip &lt;code&gt;pm2 save&lt;/code&gt; and your app won't come back after a reboot. Simple as that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Managing Multiple Applications
&lt;/h2&gt;

&lt;p&gt;PM2 handles multiple apps without issue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 start /opt/app1/server.js &lt;span class="nt"&gt;--name&lt;/span&gt; api &lt;span class="nt"&gt;-i&lt;/span&gt; 2
pm2 start /opt/app2/app.js &lt;span class="nt"&gt;--name&lt;/span&gt; frontend
pm2 start /opt/app3/worker.js &lt;span class="nt"&gt;--name&lt;/span&gt; worker

pm2 save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────┬──────────┬─────────┬───┬─────┬──────────┐
│ id │ name     │ mode    │ ↺ │ cpu │ memory   │
├────┼──────────┼─────────┼───┼─────┼──────────┤
│ 0  │ api      │ cluster │ 0 │ 0%  │ 42.1 MB  │
│ 1  │ api      │ cluster │ 0 │ 0%  │ 41.8 MB  │
│ 2  │ frontend │ fork    │ 0 │ 0%  │ 38.5 MB  │
│ 3  │ worker   │ fork    │ 0 │ 0%  │ 35.2 MB  │
└────┴──────────┴─────────┴───┴─────┴──────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Monitoring
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Real-Time Dashboard
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 monit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terminal UI showing CPU, memory, logs, and metadata per process.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick Health Check
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 show myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Outputs uptime, restart count, memory usage, log file paths, and more.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;App crash-looping:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 logs myapp &lt;span class="nt"&gt;--err&lt;/span&gt; &lt;span class="nt"&gt;--lines&lt;/span&gt; 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common culprits: missing env vars, port conflicts, unhandled promise rejections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Port already in use:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;lsof &lt;span class="nt"&gt;-i&lt;/span&gt; :3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;PM2 shows &lt;code&gt;errored&lt;/code&gt; status:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 describe myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;High &lt;code&gt;restart_time&lt;/code&gt; = crash loop. Check error logs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We Have So Far
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✅ Part 1: NVM + PM2 + startup + log rotation&lt;/li&gt;
&lt;li&gt;✅ Part 2: App running with PM2, cluster mode, memory limits, zero-downtime reloads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app is running on &lt;code&gt;localhost:3000&lt;/code&gt; but not exposed to the internet yet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/khimananda/running-your-nodejs-application-with-pm2-2nfk-temp-slug-5384205" class="crayons-btn crayons-btn--primary"&gt;Follow for Part 3 — Reverse Proxy with Nginx and Apache2&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>node</category>
      <category>devops</category>
      <category>pm2</category>
      <category>linux</category>
    </item>
    <item>
      <title>Running Multiple Java Apps with Different JDK Versions on One Server</title>
      <dc:creator>khimananda Oli</dc:creator>
      <pubDate>Thu, 19 Feb 2026 09:03:10 +0000</pubDate>
      <link>https://forem.com/khimananda/running-multiple-java-apps-with-different-jdk-versions-on-one-server-3g9o</link>
      <guid>https://forem.com/khimananda/running-multiple-java-apps-with-different-jdk-versions-on-one-server-3g9o</guid>
      <description>&lt;h1&gt;
  
  
  Running Multiple Java Apps with Different JDK Versions on One Server
&lt;/h1&gt;

&lt;p&gt;Got microservices on different Java versions? Need to run them on one box? Here's the no-BS guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Install multiple JDKs via package manager (they coexist fine)&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;absolute paths&lt;/strong&gt; to Java binary in systemd services&lt;/li&gt;
&lt;li&gt;Don't rely on &lt;code&gt;JAVA_HOME&lt;/code&gt; or system default&lt;/li&gt;
&lt;li&gt;Plan memory — multiple JVMs add up fast&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Linux has one &lt;code&gt;java&lt;/code&gt; in PATH. Running &lt;code&gt;java -jar app.jar&lt;/code&gt; uses whatever that default is.&lt;/p&gt;

&lt;p&gt;Your apps need Java 11, 17, and 21. Now what?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Don't use the default. Point each app to its specific Java binary.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Install Multiple JDKs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Ubuntu/Debian
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update
apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; openjdk-11-jre-headless &lt;span class="se"&gt;\&lt;/span&gt;
               openjdk-17-jre-headless &lt;span class="se"&gt;\&lt;/span&gt;
               openjdk-21-jre-headless
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Amazon Linux / RHEL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yum &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; java-11-amazon-corretto-headless &lt;span class="se"&gt;\&lt;/span&gt;
               java-17-amazon-corretto-headless &lt;span class="se"&gt;\&lt;/span&gt;
               java-21-amazon-corretto-headless
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /usr/lib/jvm/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paths you'll see:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Distro&lt;/th&gt;
&lt;th&gt;Java 11&lt;/th&gt;
&lt;th&gt;Java 17&lt;/th&gt;
&lt;th&gt;Java 21&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/usr/lib/jvm/java-11-openjdk-amd64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/usr/lib/jvm/java-17-openjdk-amd64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/usr/lib/jvm/java-21-openjdk-amd64&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Linux&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/usr/lib/jvm/java-11-amazon-corretto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/usr/lib/jvm/java-17-amazon-corretto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/usr/lib/jvm/java-21-amazon-corretto&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Step 2: Systemd Services with Explicit Java Paths
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Java 11 App
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/legacy-api.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Legacy API (Java 11)&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;appuser&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/apps/legacy-api&lt;/span&gt;

&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/lib/jvm/java-11-openjdk-amd64/bin/java &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;-Xms256m -Xmx512m &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;-jar /opt/apps/legacy-api/legacy-api.jar&lt;/span&gt;

&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Java 17 App
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/main-api.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Main API (Java 17)&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;appuser&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/apps/main-api&lt;/span&gt;

&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/lib/jvm/java-17-openjdk-amd64/bin/java &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;-Xms512m -Xmx1g &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;-XX:+UseG1GC &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;-jar /opt/apps/main-api/main-api.jar&lt;/span&gt;

&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Java 21 App (with ZGC)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/new-api.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;New API (Java 21)&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;appuser&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/apps/new-api&lt;/span&gt;

&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/lib/jvm/java-21-openjdk-amd64/bin/java &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;-Xms512m -Xmx1g &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;-XX:+UseZGC &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;-XX:+ZGenerational &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="s"&gt;-jar /opt/apps/new-api/new-api.jar&lt;/span&gt;

&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Enable All
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; legacy-api main-api new-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: Using Environment Files (Cleaner)
&lt;/h2&gt;

&lt;p&gt;For multiple apps, use &lt;code&gt;.env&lt;/code&gt; files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /opt/apps/main-api/.env&lt;/span&gt;
&lt;span class="nv"&gt;JAVA_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/lib/jvm/java-17-openjdk-amd64
&lt;span class="nv"&gt;JAVA_OPTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-Xms512m&lt;/span&gt; &lt;span class="nt"&gt;-Xmx1g&lt;/span&gt; &lt;span class="nt"&gt;-XX&lt;/span&gt;:+UseG1GC
&lt;span class="nv"&gt;APP_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8082
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/main-api.service
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;appuser&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/apps/main-api/.env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/bash -c 'exec $JAVA_HOME/bin/java $JAVA_OPTS -Dserver.port=$APP_PORT -jar /opt/apps/main-api/main-api.jar'&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Why bash wrapper?&lt;/strong&gt; Systemd doesn't do shell-style variable expansion. &lt;code&gt;${JAVA_HOME}&lt;/code&gt; in &lt;code&gt;ExecStart&lt;/code&gt; is passed as literal string. Wrapping in &lt;code&gt;bash -c&lt;/code&gt; fixes this.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Bonus: ASG User Data Script
&lt;/h2&gt;

&lt;p&gt;For Auto Scaling Groups — generate services dynamically at boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="c"&gt;# Install JDKs&lt;/span&gt;
yum &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; java-11-amazon-corretto-headless &lt;span class="se"&gt;\&lt;/span&gt;
               java-17-amazon-corretto-headless &lt;span class="se"&gt;\&lt;/span&gt;
               java-21-amazon-corretto-headless

&lt;span class="c"&gt;# Config: name|java_version|s3_path|port|heap&lt;/span&gt;
&lt;span class="nv"&gt;APPS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"legacy-api|11|s3://bucket/legacy-api.jar|8081|512m"&lt;/span&gt;
  &lt;span class="s2"&gt;"main-api|17|s3://bucket/main-api.jar|8082|1g"&lt;/span&gt;
  &lt;span class="s2"&gt;"new-api|21|s3://bucket/new-api.jar|8083|1g"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/apps

&lt;span class="k"&gt;for &lt;/span&gt;app_config &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;APPS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; name java_ver s3_path port heap &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$app_config&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/apps/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;s3_path&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; /opt/apps/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.jar

  &lt;span class="nv"&gt;JAVA_BIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/usr/lib/jvm/java-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;java_ver&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-amazon-corretto/bin/java"&lt;/span&gt;

  &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; &amp;gt; /etc/systemd/system/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.service
[Unit]
Description=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt; (Java &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;java_ver&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;)
After=network.target

[Service]
User=ec2-user
ExecStart=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;JAVA_BIN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt; -Xms256m -Xmx&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;heap&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt; -Dserver.port=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt; -jar /opt/apps/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.jar
Restart=always

[Install]
WantedBy=multi-user.target
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="k"&gt;done

&lt;/span&gt;systemctl daemon-reload

&lt;span class="k"&gt;for &lt;/span&gt;app_config &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;APPS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$app_config&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Memory Planning
&lt;/h2&gt;

&lt;p&gt;Multiple JVMs = plan carefully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;t3.large (8 GB) example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OS reserved:     ~1 GB
legacy-api:      -Xmx512m
main-api:        -Xmx1g  
new-api:         -Xmx1g
worker:          -Xmx512m
scheduler:       -Xmx256m
----------------------------
Total heap:      ~3.3 GB
With overhead:   ~5-6 GB
Buffer:          ~2 GB ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JVMs use more than heap (metaspace, threads, native). Leave buffer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verify Runtime Versions
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Quick check&lt;/span&gt;
ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;java

&lt;span class="c"&gt;# Detailed&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;pid &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"java.*jar"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== PID: &lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt; ==="&lt;/span&gt;
  &lt;span class="nb"&gt;cat&lt;/span&gt; /proc/&lt;span class="nv"&gt;$pid&lt;/span&gt;/cmdline | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\0'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;
  &lt;span class="nb"&gt;echo
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== PID: 1234 ===
/usr/lib/jvm/java-11-openjdk-amd64/bin/java -Xms256m -jar /opt/apps/legacy-api.jar

=== PID: 1235 ===
/usr/lib/jvm/java-17-openjdk-amd64/bin/java -Xms512m -jar /opt/apps/main-api.jar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;List JDKs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ls /usr/lib/jvm/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check default&lt;/td&gt;
&lt;td&gt;&lt;code&gt;java -version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change default&lt;/td&gt;
&lt;td&gt;&lt;code&gt;update-alternatives --config java&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find process Java&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ls -l /proc/&amp;lt;PID&amp;gt;/exe&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  When NOT to Use This
&lt;/h2&gt;

&lt;p&gt;Use containers instead when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Need independent scaling per service&lt;/li&gt;
&lt;li&gt;✅ High deploy frequency
&lt;/li&gt;
&lt;li&gt;✅ Team prefers immutable infra&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;EC2 multi-JVM approach = cost savings, more ops overhead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Running multiple Java versions on one server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install&lt;/strong&gt; all versions via package manager&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use absolute paths&lt;/strong&gt; in systemd services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never rely&lt;/strong&gt; on system defaults&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan memory&lt;/strong&gt; — JVMs add up fast&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No SDKMAN complexity. No Docker overhead. Just explicit paths and systemd.&lt;/p&gt;




&lt;p&gt;Questions? Drop them below. 👇&lt;/p&gt;

</description>
      <category>java</category>
      <category>devops</category>
      <category>linux</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Environment Variable Naming Will Break Your App — Here's How Docker, systemd, and Kubernetes Handle It Differently</title>
      <dc:creator>khimananda Oli</dc:creator>
      <pubDate>Wed, 18 Feb 2026 10:56:02 +0000</pubDate>
      <link>https://forem.com/khimananda/environment-variable-naming-will-break-your-app-heres-how-docker-systemd-and-kubernetes-handle-220h</link>
      <guid>https://forem.com/khimananda/environment-variable-naming-will-break-your-app-heres-how-docker-systemd-and-kubernetes-handle-220h</guid>
      <description>&lt;p&gt;A Spring Boot application was crash-looping over 5,000 times on an EC2 instance managed by systemd. The logs showed nothing obvious — just &lt;code&gt;status=1/FAILURE&lt;/code&gt; every 30 seconds. No connection errors, no missing JARs, no OOM kills.&lt;/p&gt;

&lt;p&gt;The culprit? &lt;strong&gt;Hyphens in environment variable names.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PAYMENT-API-PROTOCOL=https
PAYMENT-API-HOST=10.0.1.50:8080
PAYMENT-CRED-USERNAME=svc-account
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These were pulled from AWS Secrets Manager and written to a &lt;code&gt;.env&lt;/code&gt; file. The systemd service used &lt;code&gt;EnvironmentFile&lt;/code&gt; to load them. And systemd was silently ignoring every single one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ignoring invalid environment assignment 'PAYMENT-API-PROTOCOL=https'
Ignoring invalid environment assignment 'PAYMENT-CRED-USERNAME=svc-account'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kicker? &lt;strong&gt;The exact same &lt;code&gt;.env&lt;/code&gt; file worked perfectly in Docker.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rules Are Different Everywhere
&lt;/h2&gt;

&lt;p&gt;Environment variable naming isn't universal. Each runtime has its own rules, and what works in one will silently break in another.&lt;/p&gt;

&lt;h3&gt;
  
  
  POSIX Standard (What Most Things Follow)
&lt;/h3&gt;

&lt;p&gt;The POSIX specification for environment variables says names must match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[a-zA-Z_][a-zA-Z0-9_]*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's letters, digits, and underscores only. No hyphens, no dots, no special characters. The name must start with a letter or underscore.&lt;/p&gt;

&lt;p&gt;Most Linux tooling follows this — &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;sh&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt;, &lt;code&gt;export&lt;/code&gt;, &lt;code&gt;systemd&lt;/code&gt;, and core utilities all enforce or expect POSIX-compliant names.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker: The Permissive One
&lt;/h3&gt;

&lt;p&gt;Docker's &lt;code&gt;--env-file&lt;/code&gt; parser is lenient. It accepts hyphens, dots, and other characters in variable names that POSIX wouldn't allow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# docker-compose.yml or --env-file&lt;/span&gt;
PAYMENT-API-PROTOCOL&lt;span class="o"&gt;=&lt;/span&gt;https    &lt;span class="c"&gt;# ✅ works in Docker&lt;/span&gt;
my.app.config&lt;span class="o"&gt;=&lt;/span&gt;value           &lt;span class="c"&gt;# ✅ works in Docker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker passes these directly to the container's process environment without validation. The container runtime doesn't care — it just injects whatever key-value pairs you give it.&lt;/p&gt;

&lt;p&gt;This is why teams that develop exclusively in Docker containers often don't realize they're using non-standard variable names — until they deploy to bare metal or systemd-managed services.&lt;/p&gt;

&lt;h3&gt;
  
  
  systemd: Strict POSIX
&lt;/h3&gt;

&lt;p&gt;systemd's &lt;code&gt;EnvironmentFile&lt;/code&gt; directive follows POSIX rules strictly. If a variable name contains a hyphen, systemd silently drops it with a log warning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ignoring invalid environment assignment 'PAYMENT-API-PROTOCOL=https'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word "silently" is doing a lot of heavy lifting here. The service still starts. The variable just isn't there. Your application boots, can't find the config it needs, and crashes — often with a confusing error that points at dependency injection or missing beans, not at the actual missing variable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/myapp.service
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/app/.env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/java -jar /opt/app/myapp.jar&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env file&lt;/span&gt;
&lt;span class="nv"&gt;PAYMENT_API_PROTOCOL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https    &lt;span class="c"&gt;# ✅ loaded&lt;/span&gt;
PAYMENT-API-PROTOCOL&lt;span class="o"&gt;=&lt;/span&gt;https    &lt;span class="c"&gt;# ❌ silently ignored&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Kubernetes: POSIX with DNS Flexibility
&lt;/h3&gt;

&lt;p&gt;Kubernetes ConfigMaps and Secrets follow POSIX for environment variable names injected via &lt;code&gt;envFrom&lt;/code&gt; or &lt;code&gt;env&lt;/code&gt;. But there's a nuance — ConfigMap &lt;em&gt;keys&lt;/em&gt; can contain hyphens and dots because they can also be mounted as files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ConfigMap keys can have hyphens&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-config&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;PAYMENT_API_PROTOCOL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https"&lt;/span&gt;        &lt;span class="c1"&gt;# ✅ works as env var&lt;/span&gt;
  &lt;span class="na"&gt;payment-api-protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https"&lt;/span&gt;        &lt;span class="c1"&gt;# ⚠️ works as file mount, NOT as env var&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you mount a ConfigMap as a volume, each key becomes a filename — and filenames can have hyphens. But if you use &lt;code&gt;envFrom&lt;/code&gt; to inject them as environment variables, Kubernetes will skip keys that aren't valid POSIX variable names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# This will silently skip non-POSIX keys&lt;/span&gt;
&lt;span class="na"&gt;envFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;configMapRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes logs a warning event on the pod, but unless you're watching events closely, you'll miss it — just like systemd.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Docker&lt;/th&gt;
&lt;th&gt;systemd&lt;/th&gt;
&lt;th&gt;Kubernetes (env)&lt;/th&gt;
&lt;th&gt;Kubernetes (volume)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Underscores&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hyphens&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌ Silent drop&lt;/td&gt;
&lt;td&gt;❌ Silent skip&lt;/td&gt;
&lt;td&gt;✅ (as filename)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dots&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌ Silent drop&lt;/td&gt;
&lt;td&gt;❌ Silent skip&lt;/td&gt;
&lt;td&gt;✅ (as filename)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error on invalid&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Warning in journal&lt;/td&gt;
&lt;td&gt;Event on pod&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POSIX compliant&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Real Danger: Silent Failures
&lt;/h2&gt;

&lt;p&gt;The common thread across systemd and Kubernetes is &lt;strong&gt;silent failure&lt;/strong&gt;. The variable isn't there, but nothing crashes at the system level — the process starts, tries to read the missing config, and fails with an application-level error.&lt;/p&gt;

&lt;p&gt;In the incident that prompted this article, the Spring Boot error was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Caused by: java.lang.IllegalArgumentException: 
  Could not resolve placeholder 'PAYMENT_API_HOST' in value "${PAYMENT_API_HOST}"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks like a missing property issue. It is — but the root cause was three layers deep: Secrets Manager had hyphens → systemd dropped the variables → Spring couldn't find them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Use Underscores Everywhere
&lt;/h3&gt;

&lt;p&gt;Just use &lt;code&gt;SCREAMING_SNAKE_CASE&lt;/code&gt; for all environment variables. It's POSIX-compliant and works everywhere — Docker, systemd, Kubernetes, bash, CI/CD pipelines, Lambda, ECS, everything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Do this&lt;/span&gt;
&lt;span class="nv"&gt;PAYMENT_API_PROTOCOL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https
&lt;span class="nv"&gt;PAYMENT_CRED_USERNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myuser

&lt;span class="c"&gt;# Not this&lt;/span&gt;
PAYMENT-API-PROTOCOL&lt;span class="o"&gt;=&lt;/span&gt;https
payment.api.protocol&lt;span class="o"&gt;=&lt;/span&gt;https
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Validate at Deploy Time
&lt;/h3&gt;

&lt;p&gt;Add a preflight check in your deployment scripts that verifies all required variables are present &lt;em&gt;before&lt;/em&gt; restarting the service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;REQUIRED_VARS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"DB_HOST DB_PORT API_KEY SECRET_TOKEN"&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;var &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$REQUIRED_VARS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;var&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;="&lt;/span&gt; .env &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FATAL: Missing &lt;/span&gt;&lt;span class="nv"&gt;$var&lt;/span&gt;&lt;span class="s2"&gt; in .env"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;systemctl restart myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This takes 5 lines and prevents crash loops at 3 AM.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Transform at the Boundary
&lt;/h3&gt;

&lt;p&gt;If you can't control the source (like a third-party Secrets Manager with legacy keys), transform at the boundary — the point where secrets are pulled into the runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# In deploy.sh — transform hyphens to underscores when pulling from Secrets Manager&lt;/span&gt;
aws secretsmanager get-secret-value &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp.prod &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'to_entries|map("\(.key | gsub("-";"_"))=\(.value|tostring)")|.[]'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Spring Boot Property Naming
&lt;/h3&gt;

&lt;p&gt;Spring Boot has its own relaxed binding that maps environment variables to properties. Use this to your advantage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# application.properties
# Spring property with dots — reads from PAYMENT_API_PROTOCOL env var automatically
&lt;/span&gt;&lt;span class="py"&gt;payment.api.protocol&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${PAYMENT_API_PROTOCOL}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But be careful with circular references. If your property name and placeholder are identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# ❌ Circular reference — Spring resolves against itself
&lt;/span&gt;&lt;span class="py"&gt;PAYMENT_API_HOST&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${PAYMENT_API_HOST}&lt;/span&gt;

&lt;span class="c"&gt;# ✅ Different names — env var resolves correctly
&lt;/span&gt;&lt;span class="py"&gt;payment.api.host&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;${PAYMENT_API_HOST}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Use &lt;code&gt;systemd-analyze&lt;/code&gt; to Verify
&lt;/h3&gt;

&lt;p&gt;Before deploying, verify your environment file is valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-analyze verify /etc/systemd/system/myapp.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or manually test what systemd will actually load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-run &lt;span class="nt"&gt;--property&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/app/.env /usr/bin/env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Environment variable naming is one of those things that "just works" until it doesn't. Docker's permissiveness masks issues that surface the moment you move to systemd or Kubernetes. The fix is simple — stick to POSIX-compliant names (letters, digits, underscores) and validate at deploy time.&lt;/p&gt;

&lt;p&gt;The 5,000+ restarts and hours of debugging could have been avoided with two things: underscores instead of hyphens, and a 5-line validation script in the deployment pipeline.&lt;/p&gt;

&lt;p&gt;Sometimes the smallest characters cause the biggest outages.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you run into similar environment variable gotchas? Drop your war stories in the comments 👇&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you enjoyed this article, you can read more DevOps and infrastructure engineering content on my personal website:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://khimananda.com" rel="noopener noreferrer"&gt;https://khimananda.com&lt;/a&gt;&lt;br&gt;
``&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>kubernetes</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
