Atuin replaces your shell history with a searchable database: fuzzy matching, filter by directory, …
Building a URL Shortener in Go: From TCP Sockets to a Working Service Building a URL Shortener in Go: From TCP Sockets to a Working Service

Summary
A URL shortener takes a long URL and hands back a short one. Visit the short URL and the server redirects you to the original. That is the entire product — and it is a genuinely good thing to build, because underneath that one sentence sits TCP, HTTP parsing, hashing and collisions, persistence, redirect semantics, and abuse protection.
We will build it in Go, standard library only. No frameworks, no database, no external services. Each step starts with a Linux command so you can watch the concept happen in your terminal, then we write the Go equivalent. And at nearly every step we will hit a real bug first, look at what broke, and then fix it.
You need a Linux or macOS terminal and Go installed. Nothing else.
What we are building
Three endpoints, and this is how a request moves through them:
flowchart TD
A["POST /shorten"] --> B{"Rate limit OK?"}
B -->|no| C["429 Too Many Requests"]
B -->|yes| D{"Valid http(s) URL?"}
D -->|no| E["400 Bad Request"]
D -->|yes| F["sha256 → 7-char code"]
F --> G{"Code taken by<br/>a different URL?"}
G -->|yes| H["extend code<br/>until unique"]
G -->|no| I["write to map + urls.txt"]
H --> I
I --> J["201 + short URL"]
K["GET /{code}"] --> L{"Code exists?"}
L -->|no| M["404 Not Found"]
L -->|yes| N["record click"]
N --> O["302 → original URL"]
P["GET /stats/{code}"] --> Q["click count,<br/>referrers, recent hits"]
To keep this readable, each step below shows only the code that changes. The complete program appears once at the end.
Expand your knowledge with Livestream From Your Phone to AWS with IVS (and What Every Setting Means)
Step 1: HTTP is just text over TCP
Before any Go, watch what a browser actually sends. Open two terminals. In the first:
nc -l 8080
In the second:
curl -v http://localhost:8080/test
The first terminal prints the raw request:
GET /test HTTP/1.1
Host: localhost:8080
User-Agent: curl/7.81.0
Accept: */*
That is all HTTP is: a request line (method, path, version), headers, a blank line, then an optional body. Type a response back into the netcat terminal and press Enter:
HTTP/1.1 200 OK
Content-Type: text/plain
Hello from netcat
Press Ctrl+C. Your response appears in the curl terminal. No magic anywhere.
The Go version
func handleConnection(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
fmt.Println("Error reading:", err)
return
}
fmt.Println("--- Received ---")
fmt.Println(string(buf[:n]))
response := "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello from Go\n"
conn.Write([]byte(response))
}
With a net.Listen("tcp", ":8080") loop calling that per connection, curl behaves
exactly as it did against netcat.
The bug: the POST body goes missing. Send a body and it sometimes shows up, sometimes does not:
curl -X POST -d '{"url":"https://example.com"}' http://localhost:8080/shorten
TCP is a byte stream with no message boundaries. One Read returns whatever has
arrived so far, which may be only the headers — the body can still be in flight.
The fix is to parse headers until the blank line, pull out Content-Length, then read
exactly that many bytes:
func handleConnection(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
requestLine, err := reader.ReadString('\n')
if err != nil {
return
}
fmt.Print("Request: ", requestLine)
contentLength := 0
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
break // blank line = end of headers
}
if strings.HasPrefix(strings.ToLower(line), "content-length:") {
parts := strings.SplitN(line, ":", 2)
contentLength, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
}
}
if contentLength > 0 {
body := make([]byte, contentLength)
if _, err := io.ReadFull(reader, body); err != nil {
return
}
fmt.Println("Body:", string(body))
}
conn.Write([]byte("HTTP/1.1 200 OK\r\n\r\nReceived\n"))
}
io.ReadFull keeps reading until the buffer is full, which is exactly the guarantee we
need. Every web framework you have used does this same parsing — now you can stop
writing it and let net/http do it.
Deepen your understanding in Running SQL Over HTTPS with the RDS Data API (Aurora PostgreSQL)
Step 2: Routing with net/http
Two endpoints to start: POST /shorten to create, GET /{code} to redirect.
func handler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/shorten" && r.Method == http.MethodPost {
handleShorten(w, r)
return
}
code := strings.TrimPrefix(r.URL.Path, "/")
if code != "" {
handleRedirect(w, r, code)
return
}
fmt.Fprintln(w, "URL Shortener is running")
}
The bug: the catch-all swallows everything. Registering "/" matches every path, so
anything that is not exactly /shorten falls through to the redirect branch:
curl -v "http://localhost:8080/shorten?url=test"
# HTTP/1.1 404 Not Found
# Short URL not found
The user used the wrong method and got told their short URL does not exist. Misleading errors like this are how people give up on an API.
Handle each shape explicitly, and only treat single-segment paths as codes:
func handler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/shorten" {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed. Use POST.", http.StatusMethodNotAllowed)
return
}
handleShorten(w, r)
return
}
if path == "/" {
fmt.Fprintln(w, "URL Shortener is running")
return
}
code := strings.TrimPrefix(path, "/")
if strings.Contains(code, "/") {
http.NotFound(w, r) // multi-segment paths are never short codes
return
}
handleRedirect(w, r, code)
}
Now a GET to /shorten returns 405 Method Not Allowed, /shorten/extra returns
404, and only /abc1234 is treated as a lookup.
Explore this further in Build Image with EC2 Image Builder Components
Step 3: Generating short codes
Every URL currently gets the hardcoded code abc123. There are two standard approaches,
and you can feel the difference from the shell.
Hash-based — deterministic, so the same URL always maps to the same code:
echo -n "https://example.com" | sha256sum | cut -c1-8
# f0e6a6a3 (identical every run)
Random — a different code every time, even for the same URL:
head -c 6 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9'
# kQ7mBx2p (different every run)
Hash-based gives free deduplication: submit the same URL twice, get the same short link. That is what we will use.
func generateCode(url string) string {
hash := sha256.Sum256([]byte(url))
return hex.EncodeToString(hash[:])
}
The bug: the “short” code is 64 characters. A full SHA-256 in hex is longer than most of the URLs you are trying to shorten:
http://localhost:8080/f0e6a6a3a0d6b6c1e2d3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5
Truncate to 7 characters — but truncating creates collisions, so we have to handle two different URLs landing on the same prefix. Extend the code until it is unique:
func generateCode(url string) string {
hash := sha256.Sum256([]byte(url))
return hex.EncodeToString(hash[:])[:7]
}
// resolveCode returns a code that is either free or already points at this URL.
func resolveCode(url string) string {
code := generateCode(url)
entry, exists := store.Get(code)
if !exists || entry.OriginalURL == url {
return code // free, or already ours
}
full := hex.EncodeToString(sha256.Sum256([]byte(url))[:])
for length := 8; length <= len(full); length++ {
code = full[:length]
if e, taken := store.Get(code); !taken || e.OriginalURL == url {
break
}
}
return code
}
Same URL in, same 7-character code out. Different URLs that collide get a slightly longer code instead of overwriting each other.
Discover related concepts in Bash Code Shortening: Writing Concise Shell Scripts
0-9a-zA-Z) give 62⁷ ≈ 3.5 trillion — about 13,000× the
address space at exactly the same visual length. Every production shortener uses base62
or similar. Hex is used here because it falls straight out of sha256sum and keeps the
shell examples honest; swapping the encoder is the natural first upgrade.Step 4: Persistence
Everything lives in a map, so a restart loses every link. The simplest durable store is a text file, one mapping per line:
echo "f0e6a6a https://example.com" >> urls.txt
grep "^f0e6a6a " urls.txt | cut -d' ' -f2-
# https://example.com
That works, and it is also a trap. grep scans the file top to bottom, so every lookup
reads everything. At 100,000 URLs and 100 requests per second that is 10 million line
reads per second — the server would crawl.
So we use both: the file for durability, a map for lookups. Load the file once at startup, serve reads from memory, append new entries to disk.
type URLStore struct {
mu sync.RWMutex
entries map[string]*URLEntry
file *os.File
}
func newURLStore(filename string) (*URLStore, error) {
s := &URLStore{entries: make(map[string]*URLEntry)}
if f, err := os.Open(filename); err == nil {
scanner := bufio.NewScanner(f)
for scanner.Scan() {
parts := strings.SplitN(scanner.Text(), " ", 2)
if len(parts) == 2 {
s.entries[parts[0]] = &URLEntry{OriginalURL: parts[1], CreatedAt: time.Now()}
}
}
f.Close()
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading %s: %w", filename, err)
}
}
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("opening %s for write: %w", filename, err)
}
s.file = f
return s, nil
}
func (s *URLStore) Save(code, url string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.entries[code] = &URLEntry{OriginalURL: url, CreatedAt: time.Now()}
if _, err := fmt.Fprintf(s.file, "%s %s\n", code, url); err != nil {
return fmt.Errorf("writing to data file: %w", err)
}
return s.file.Sync() // flush to disk before we report success
}
func (s *URLStore) Get(code string) (*URLEntry, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, exists := s.entries[code]
return entry, exists
}
Create a couple of links, stop the server, start it again, and the old codes still resolve. This is the same shape Redis uses: reads from memory, writes appended to disk.
Uncover more details in Build and Deploy a Go Lambda Function
file.Sync() on every write is deliberate. Without it the mapping can sit in the OS page
cache when you return a short URL to the user — and a crash in that window hands them a
link that resolves to nothing. It costs an fsync per creation, which is the right trade
for a write-rare, read-heavy service.Step 5: Redirects and click analytics
The redirect is the product. Watch one at the protocol level:
curl -o /dev/null -s -w "Status: %{http_code}\nRedirect: %{redirect_url}\n" \
http://localhost:8080/f0e6a6a
# Status: 302
# Redirect: https://example.com
Recording a click is a few extra fields on the entry:
func (s *URLStore) RecordClick(code string, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
entry, exists := s.entries[code]
if !exists {
return
}
entry.Clicks = append(entry.Clicks, ClickRecord{
Timestamp: time.Now(),
UserAgent: r.UserAgent(),
IP: clientIP(r),
Referrer: r.Referer(),
})
}
The bug: a 301 silently kills your analytics. Redirect with
http.StatusMovedPermanently and everything looks perfect in curl. Then open the link in
a browser twice and the click count stays at 1.
301 means permanently moved, so the browser caches it and goes straight to the destination forever after. Your server never sees the second visit. The counter is not broken — the requests genuinely never arrive.
func handleRedirect(w http.ResponseWriter, r *http.Request, code string) {
entry, exists := store.Get(code)
if !exists {
http.Error(w, "Short URL not found", http.StatusNotFound)
return
}
store.RecordClick(code, r)
http.Redirect(w, r, entry.OriginalURL, http.StatusFound) // 302, never 301
}
302 costs one round trip per click and buys accurate counts. For a shortener, whose entire value is knowing what got clicked, that is not a close call.
Journey deeper into this topic with Master tmux: From Multiplexer to a Go Session Manager
Step 6: Rate limiting
An open POST /shorten is an invitation to fill your disk. Give each IP a bucket of
tokens that refills on a timer:
const (
maxTokens = 10
resetPeriod = time.Minute
)
func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
bucket, exists := rl.buckets[ip]
if !exists {
rl.buckets[ip] = &Bucket{tokens: maxTokens - 1, lastReset: time.Now()}
return true
}
if time.Since(bucket.lastReset) > resetPeriod {
bucket.tokens = maxTokens
bucket.lastReset = time.Now()
}
if bucket.tokens <= 0 {
return false
}
bucket.tokens--
return true
}
Prove it works:
for i in $(seq 1 15); do
curl -s -o /dev/null -w "Request $i: HTTP %{http_code}\n" -X POST \
-H "Content-Type: application/json" \
-d "{\"url\":\"https://example.com/$i\"}" \
http://localhost:8080/shorten
done
The first ten return 201, the rest 429.
Enrich your learning with Go + Nginx: Deploy a Go API Behind a Reverse Proxy
This is a fixed window, not a true token bucket — all ten tokens return at once when
the minute rolls over, so a client can send 10 at 0:59 and 10 more at 1:01. Fine for
stopping casual abuse, not fine as a hard guarantee. A real token bucket refills
continuously (tokens += elapsed * rate).
The buckets map also grows forever, one entry per IP seen. In production you would evict
idle buckets or use a fixed-size cache.
Where this design breaks
Everything above works, and it is worth being clear about where it stops working — this is the part most tutorials skip.
| Limit | What happens | Fix |
|---|---|---|
| One instance only | The map is per-process. Run two copies behind a load balancer and each sees half the links | Move state to Redis, Postgres or DynamoDB |
| Clicks are memory-only | Analytics reset on restart; only the mappings are persisted | Append click events to their own file or a database |
| Write lock on every redirect | RecordClick takes the write lock, so redirects serialise on the hot path | Buffer clicks in a channel and flush in a goroutine |
| The file only grows | Deletes and updates are not possible, and startup gets slower | Periodic compaction, or a real database |
| Hex codes | 268M codes instead of 3.5 trillion | Base62 encoding |
clientIP and IPv6 | Splitting on the last : mangles IPv6 addresses | net.SplitHostPort |
| No TLS | Short links over plain HTTP | Terminate TLS at nginx or a load balancer |
None of these matter for a service you are running locally to understand the mechanics. All of them matter the moment it takes real traffic.
Gain comprehensive insights from Handling a Million Users an Hour with AWS Lambda (Think Like a System Designer)
The complete service
Everything above, assembled — standard library only.
Master this concept through Service Health Checks: From curl to a Go Health Monitor
package main
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
)
const (
dataFile = "urls.txt"
maxTokens = 10
resetPeriod = time.Minute
)
type ClickRecord struct {
Timestamp time.Time `json:"timestamp"`
UserAgent string `json:"user_agent"`
IP string `json:"ip"`
Referrer string `json:"referrer"`
}
type URLEntry struct {
OriginalURL string
Clicks []ClickRecord
CreatedAt time.Time
}
type URLStore struct {
mu sync.RWMutex
entries map[string]*URLEntry
file *os.File
}
type RateLimiter struct {
mu sync.Mutex
buckets map[string]*Bucket
}
type Bucket struct {
tokens int
lastReset time.Time
}
type ShortenRequest struct {
URL string `json:"url"`
}
type ShortenResponse struct {
ShortURL string `json:"short_url"`
Code string `json:"code"`
}
type StatsResponse struct {
Code string `json:"code"`
OriginalURL string `json:"original_url"`
ClickCount int `json:"click_count"`
CreatedAt time.Time `json:"created_at"`
LastClicked string `json:"last_clicked"`
TopReferrers map[string]int `json:"top_referrers"`
}
var (
store *URLStore
limiter *RateLimiter
)
// --- store ---
func newURLStore(filename string) (*URLStore, error) {
s := &URLStore{entries: make(map[string]*URLEntry)}
if f, err := os.Open(filename); err == nil {
scanner := bufio.NewScanner(f)
count := 0
for scanner.Scan() {
parts := strings.SplitN(scanner.Text(), " ", 2)
if len(parts) == 2 {
s.entries[parts[0]] = &URLEntry{OriginalURL: parts[1], CreatedAt: time.Now()}
count++
}
}
f.Close()
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading %s: %w", filename, err)
}
fmt.Printf("Loaded %d URLs from %s\n", count, filename)
}
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("opening %s for write: %w", filename, err)
}
s.file = f
return s, nil
}
func (s *URLStore) Save(code, url string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.entries[code] = &URLEntry{OriginalURL: url, CreatedAt: time.Now()}
if _, err := fmt.Fprintf(s.file, "%s %s\n", code, url); err != nil {
return fmt.Errorf("writing to data file: %w", err)
}
return s.file.Sync()
}
func (s *URLStore) Get(code string) (*URLEntry, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, exists := s.entries[code]
return entry, exists
}
func (s *URLStore) RecordClick(code string, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
entry, exists := s.entries[code]
if !exists {
return
}
entry.Clicks = append(entry.Clicks, ClickRecord{
Timestamp: time.Now(),
UserAgent: r.UserAgent(),
IP: clientIP(r),
Referrer: r.Referer(),
})
}
func (s *URLStore) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.entries)
}
// --- codes ---
func generateCode(url string) string {
hash := sha256.Sum256([]byte(url))
return hex.EncodeToString(hash[:])[:7]
}
func resolveCode(url string) string {
code := generateCode(url)
if entry, exists := store.Get(code); !exists || entry.OriginalURL == url {
return code
}
sum := sha256.Sum256([]byte(url))
full := hex.EncodeToString(sum[:])
for length := 8; length <= len(full); length++ {
code = full[:length]
if e, taken := store.Get(code); !taken || e.OriginalURL == url {
break
}
}
return code
}
// --- rate limiting ---
func newRateLimiter() *RateLimiter {
return &RateLimiter{buckets: make(map[string]*Bucket)}
}
func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
bucket, exists := rl.buckets[ip]
if !exists {
rl.buckets[ip] = &Bucket{tokens: maxTokens - 1, lastReset: time.Now()}
return true
}
if time.Since(bucket.lastReset) > resetPeriod {
bucket.tokens = maxTokens
bucket.lastReset = time.Now()
}
if bucket.tokens <= 0 {
return false
}
bucket.tokens--
return true
}
// --- helpers ---
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return strings.TrimSpace(strings.SplitN(fwd, ",", 2)[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr) // IPv6-safe
if err != nil {
return r.RemoteAddr
}
return host
}
// baseURL builds the short link from the request, so the service works on any host.
func baseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return fmt.Sprintf("%s://%s", scheme, r.Host)
}
// --- handlers ---
func main() {
var err error
if store, err = newURLStore(dataFile); err != nil {
fmt.Println("Error initializing store:", err)
os.Exit(1)
}
defer store.file.Close()
limiter = newRateLimiter()
http.HandleFunc("/", handler)
fmt.Printf("URL Shortener on :8080 — %d URLs loaded\n", store.Count())
fmt.Println("POST /shorten · GET /{code} · GET /stats/{code}")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Println("Error:", err)
}
}
func handler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/shorten" {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed. Use POST.", http.StatusMethodNotAllowed)
return
}
handleShorten(w, r)
return
}
if path == "/" {
fmt.Fprintln(w, "URL Shortener is running. POST to /shorten to create a short URL.")
return
}
trimmed := strings.TrimPrefix(path, "/")
if statsCode, ok := strings.CutPrefix(trimmed, "stats/"); ok {
if statsCode != "" && !strings.Contains(statsCode, "/") {
handleStats(w, r, statsCode)
return
}
}
if strings.Contains(trimmed, "/") {
http.NotFound(w, r)
return
}
handleRedirect(w, r, trimmed)
}
func handleShorten(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if !limiter.Allow(ip) {
http.Error(w, "Rate limit exceeded. Max 10 URLs per minute.", http.StatusTooManyRequests)
return
}
var req ShortenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.URL == "" {
http.Error(w, "Invalid request. Send JSON with a 'url' field.", http.StatusBadRequest)
return
}
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
http.Error(w, "URL must start with http:// or https://", http.StatusBadRequest)
return
}
code := resolveCode(req.URL)
resp := ShortenResponse{
ShortURL: fmt.Sprintf("%s/%s", baseURL(r), code),
Code: code,
}
w.Header().Set("Content-Type", "application/json")
// Already shortened: return the existing link instead of rewriting it.
if entry, exists := store.Get(code); exists && entry.OriginalURL == req.URL {
json.NewEncoder(w).Encode(resp)
return
}
if err := store.Save(code, req.URL); err != nil {
http.Error(w, "Failed to save URL", http.StatusInternalServerError)
return
}
fmt.Printf("CREATE /%s -> %s\n", code, req.URL)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
func handleRedirect(w http.ResponseWriter, r *http.Request, code string) {
entry, exists := store.Get(code)
if !exists {
http.Error(w, "Short URL not found", http.StatusNotFound)
return
}
store.RecordClick(code, r)
fmt.Printf("REDIRECT /%s -> %s from %s\n", code, entry.OriginalURL, clientIP(r))
http.Redirect(w, r, entry.OriginalURL, http.StatusFound)
}
func handleStats(w http.ResponseWriter, r *http.Request, code string) {
entry, exists := store.Get(code)
if !exists {
http.Error(w, "Short URL not found", http.StatusNotFound)
return
}
store.mu.RLock()
clicks := append([]ClickRecord(nil), entry.Clicks...) // copy, do not hold the lock
created := entry.CreatedAt
original := entry.OriginalURL
store.mu.RUnlock()
lastClicked := "never"
if len(clicks) > 0 {
lastClicked = clicks[len(clicks)-1].Timestamp.Format(time.RFC3339)
}
referrers := make(map[string]int)
for _, c := range clicks {
ref := c.Referrer
if ref == "" {
ref = "direct"
}
referrers[ref]++
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(StatsResponse{
Code: code,
OriginalURL: original,
ClickCount: len(clicks),
CreatedAt: created,
LastClicked: lastClicked,
TopReferrers: referrers,
})
}
Running it
go run main.go
# create
curl -s -X POST -H "Content-Type: application/json" \
-d '{"url":"https://go.dev/doc/"}' http://localhost:8080/shorten
# {"short_url":"http://localhost:8080/a1b2c3d","code":"a1b2c3d"}
# click it a few times
curl -s -o /dev/null http://localhost:8080/a1b2c3d
# stats
curl -s http://localhost:8080/stats/a1b2c3d
What you actually learned
The shortener is the excuse. The transferable parts are: HTTP is text over a byte stream with no message boundaries; a catch-all route will happily hide your real errors; truncating a hash means owning the collision case; memory plus an append-only file is a legitimate storage design; 301 versus 302 is an analytics decision, not a style choice; and any open write endpoint needs a limiter in front of it.
Every line here is standard library. No frameworks, no database, no dependencies.
Delve into specifics at EC2 Storage in Plain English: Root Volumes vs File Systems
Keep Reading
- Go + Nginx: Deploy a Go API Behind a Reverse Proxy — put this behind nginx with TLS.
- Containers From Scratch in Go — package it up.
- Go + DynamoDB: Build a Simple CRUD App — replace the file store so it can run on more than one instance.
Similar Articles
Related Content
More from development
Cut AWS Lambda cost: what you are actually billed for, why more memory is rarely cheaper, the exact …
How I turned a Hugo blog into a public MCP server on Netlify — tools, resources and prompts over a …
You Might Also Like
Learn tmux from scratch — sessions, windows, panes, and scripting — then build a Go CLI tool that …
Build a log aggregator in Go from scratch. Tail files with inotify, survive log rotation, parse …
Knowledge Quiz
Test your general knowledge with this quick quiz!
A set of multiple-choice questions to test your knowledge.
Take as much time as you need.
Your score will be shown at the end.
Question 1 of 5
Quiz Complete!
Your score: 0 out of 5
Loading next question...
Contents

