full walkthrough — ec2 setup, nginx, ssl, systemd, and github actions ci/cd for a turborepo go backend
spinning up an ec2 instance, sshing in, setting up nginx as a reverse proxy, getting ssl via certbot, keeping the server alive with systemd, and wiring up github actions for auto-deploy on push. this was done for a turborepo monorepo where only the go/gin backend needed to be on ec2 — frontend was already on vercel.
go to console.aws.amazon.com and sign in with your root user email. search for ec2 in the search bar or find it in recently used.
click launch instance and fill in the form:
my-project-backend)dnf package manager)t3.micro — 2 vcpu, 1gb ram, free tier eligible.pemleave storage as default (8gb gp3 is fine for a backend). click launch instance.
first, lock down your .pem file so only your user can read it:
chmod 400 ~/Downloads/your-key.pemget your instance's public ip from the ec2 dashboard (instances → your instance → public ipv4 address).
then ssh in:
ssh -i ~/Downloads/your-key.pem ec2-user@your-public-ipit'll ask you to confirm the fingerprint the first time — type yes. you're in. ec2-user is the default root user on amazon linux.
dnf is the default package manager on amazon linux (like apt on ubuntu). the -y flag auto-answers yes to all prompts during install:
sudo dnf install git golang -yverify:
go version
git --versiongit clone https://github.com/your-username/your-repo.git
cd your-repoyour go backend reads from a .env file via godotenv.Load() which looks for .env in the current working directory when the binary runs. so put it in your backend directory.
the easiest way is to scp it from your local machine. run this from your local terminal (not ec2) in your turborepo root:
scp -i ~/Downloads/your-key.pem .env.production ec2-user@your-public-ip:~/your-repo/apps/backend/.envthis copies your local .env.production to apps/backend/.env on ec2. godotenv will pick it up automatically.
make sure these are set correctly in the .env:
GIN_MODE=release
PORT=5000
FRONTEND_URL=https://www.yourdomain.com
COOKIE_DOMAIN=.yourdomain.com
DATABASE_URL=...
JWT_ACCESS_SECRET=...
JWT_REFRESH_SECRET=...
# ... rest of your secretsa few things that'll bite you if wrong:
GIN_MODE must be release in prod — debug mode can mess with cookie behaviorFRONTEND_URL must exactly match the origin your frontend runs on (with or without www matters for cors)COOKIE_DOMAIN needs the leading dot (.yourdomain.com) so cookies work across subdomainscd your-repo/apps/backend
go build -o server ./cmd/server
chmod +x servergo build compiles everything into a single binary called server. chmod +x makes it executable. test it first:
./serverif it boots without errors, kill it with ctrl+c and move on to systemd.
install nginx:
sudo dnf install nginx -yedit the nginx config:
sudo vim /etc/nginx/nginx.confreplace the contents with:
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name api.yourdomain.com;
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}this tells nginx to listen on port 80 and forward all requests to your go server on port 5000. the proxy_set_header lines pass along the real client ip and protocol to your app.
test the config for syntax errors:
sudo nginx -tstart and enable nginx:
sudo systemctl start nginx
sudo systemctl enable nginxenable makes nginx auto-start on reboot.
go to your dns provider (cloudflare in this case) and add an A record:
api (for api.yourdomain.com)the proxy must be off for the api subdomain — when cloudflare proxies the request it strips/modifies cookie headers which breaks httponly cookie handling. your frontend subdomain can stay proxied.
to check if dns has propagated:
# wait a few minutes then check
nslookup api.yourdomain.comor use whatsmydns.net to see propagation across regions.
install certbot and the nginx plugin:
sudo dnf install certbot python3-certbot-nginx -ygenerate the ssl certificate:
sudo certbot --nginx -d api.yourdomain.comcertbot will automatically modify your nginx config to handle https and set up redirects from http to https. it also sets up auto-renewal via a cron job.
after this your api is accessible at https://api.yourdomain.com.
running ./server & or nohup ./server & works but dies on reboot. systemd is the proper way — it manages the process lifecycle, auto-restarts on crash, and starts on boot.
create a service file:
sudo vim /etc/systemd/system/your-project.service[Unit]
Description=Your Project Backend
After=network.target
[Service]
Type=simple
User=ec2-user
WorkingDirectory=/home/ec2-user/your-repo/apps/backend
ExecStart=/home/ec2-user/your-repo/apps/backend/server
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetWorkingDirectory — this is where godotenv looks for .env, must be the backend dirExecStart — full absolute path to your binaryRestart=on-failure — auto-restart if the process crashesRestartSec=5 — wait 5 seconds before restartingreload systemd and start the service:
sudo systemctl daemon-reload
sudo systemctl enable your-project
sudo systemctl start your-projectcheck status:
sudo systemctl status your-projectshould show active (running). useful commands going forward:
sudo systemctl restart your-project # restart after rebuild
sudo systemctl stop your-project # stop
sudo journalctl -u your-project -n 50 # view logsevery time you push to main with changes in apps/backend, this workflow sshs into ec2, pulls the latest code, rebuilds the binary, and restarts the systemd service.
go to your repo on github → settings → secrets and variables → actions → new repository secret.
add these three:
| secret | value |
| ------------- | --------------------------------- |
| EC2_HOST | your ec2 public ip |
| EC2_USER | ec2-user |
| EC2_SSH_KEY | full contents of your .pem file |
to copy your .pem contents on linux:
xsel --clipboard --input < ~/Downloads/your-key.pemthen paste into the secret field.
create .github/workflows/deploy.yml in your repo root:
name: Deploy Backend
on:
push:
branches:
- main
paths:
- "apps/backend/**"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to EC2
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd ~/your-repo
git pull origin main
cd apps/backend
go build -o server ./cmd/server
chmod +x server
sudo systemctl restart your-projectthe paths filter means the workflow only runs when something inside apps/backend changes — pushing frontend changes won't trigger a backend redeploy.
runs-on: ubuntu-latest is the github actions runner (github's server that executes the workflow) — not your ec2. it just sshs into your ec2 from there.
push the workflow file to trigger a test run:
git add .github/workflows/deploy.yml
git commit -m "ci: add backend auto deploy workflow"
git push origin mainwatch it run under the actions tab in your github repo.
if you need to deploy manually:
ssh -i ~/Downloads/your-key.pem ec2-user@your-public-ip
cd ~/your-repo
git pull origin main
cd apps/backend
go build -o server ./cmd/server
chmod +x server
sudo systemctl restart your-projectran scp from inside ec2 scp copies between two machines. always run it from your local terminal, not inside the ssh session.
multi-line paste froze the ssh session
pasting multiple lines at once into the ssh terminal freezes it and you can't type. paste line by line. if it freezes press enter ~ . to kill the session without closing the terminal. or disable bracketed paste mode first:
printf "\e[?2004l"cors blocking requests
FRONTEND_URL was set to https://brewandbakeacademy.com but the frontend ran on https://www.brewandbakeacademy.com. cors does exact origin matching — the www matters.
cookies not being set correctly
GIN_MODE was still debug in prod. always set it to release:
GIN_MODE=releasecookie domain not working across subdomains
COOKIE_DOMAIN needs a leading dot so cookies are valid across all subdomains:
COOKIE_DOMAIN=.yourdomain.comcloudflare proxy breaking cookies the orange cloud proxy was on for the api subdomain. cloudflare strips and modifies cookie headers in transit. set it to grey (dns only) for the api subdomain.
port conflict when switching to systemd
the nohup background process was still running on port 5000 when systemd tried to start. systemd kept failing with address already in use. always kill the old process first:
pkill serverchose the wrong aws region us-east-1 is the default but ap-south-1 (mumbai) is the closest region to nepal. lower latency for your users — always check the region before launching.