sethserver.com Subscribe
A retro-style cartoon illustration featuring an orange and teal robot with claw hands and an antenna standing next to a turquoise and white rocket ship against a dark space background. The scene includes colorful geometric planets (one with rings, one wireframe), stars in pink and yellow, small circles, and a trail of multicolored triangular shapes beneath the rocket.

AWS LightSail: The Final Piece of the Replit to Production Puzzle

By Seth Black • Updated: September 17, 2026

Programming · 10 min read

Building serious apps on Replit? This article is part of a complete production deployment guide. Download the full 130-page book here.

I worked at a quite a few places with manual deployments.

The kind of deployments that require someone to SSH into production, run a series of commands in the correct order, maybe run a database migration or two, and pray nothing breaks.

Something inevitably breaks. The "deployment person" goes on vacation or gets hit by the proverbial bus (e.g. gets laid off so execs can buy new Ferraris to celebrate their cost-cutting abilities). The backup... person? misses a step.

I've learned two things:

  1. Always test like a user (I once took down an e-commerce site on launch weekend by not testing in IE because Firefox was shiny and new).
  2. Deployments should be boring, automated, and reversible.

That's the whole game here. Replit is a great place to build. But production needs logging, backups, health checks, and a deployment system that works even if the one hero engineer is out sick.

I use tags and containers. Git tags trigger GitHub Actions, which builds a Docker image and deploys it to AWS Lightsail Container Service.

This closes the loop:


Tags: the "no bikeshedding" version number

Semantic versions are great for libraries. For a startup shipping constantly, they turn into "major or minor change" meetings. Gah.

Date-based tags are simpler: YYYYMMDDHHMM

tag=$(date +%Y%m%d%H%M)
git tag $tag
git push origin $tag

Example: 202501201430 = January 20, 2025 at 2:30 PM

That's it. A deploy is a single action with a clean audit trail.

Why date tags work in real life

A practical detail: tags are immutable by convention. You can delete and recreate them. Don't. Lock down who can push tags.

Staging vs prod tags

If you want staging vs prod, suffix it:

git tag 202501201430-staging
git tag 202501201445-prod

Your GitHub Actions trigger needs to match both patterns (we'll do that below).

Making it one command (so people actually do it)

If deploying takes three commands and a prayer, it won't happen consistently. Add an alias:

alias deploy='tag=$(date +%Y%m%d%H%M) && git tag $tag && git push origin $tag'

Then deploying is:

deploy

If you want staging:

alias deploy-staging='tag=$(date +%Y%m%d%H%M)-staging && git tag $tag && git push origin $tag'
alias deploy-prod='tag=$(date +%Y%m%d%H%M)-prod && git tag $tag && git push origin $tag'

Why containers

Containers solve a bunch of annoying problems:

Lightsail Container Service is basically Docker hosting with fewer moving parts. No SSH. No nginx ritual. No server drift.


Dockerfile (minimum viable container)

A basic Flask Dockerfile:

FROM python:3.14-slim
WORKDIR /app

COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt

COPY . .
EXPOSE 8080
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8080", "app:app"]

If your app listens on 8080, Lightsail can run it.

Use .dockerignore

Create .dockerignore:

.git
.env
__pycache__
*.pyc
.venv
node_modules
tests
README.md
.DS_Store

Dependency caching (fast builds)

Keep this order:

COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

Node apps (same idea)

If you're on Express:

FROM node:24-slim
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .
EXPOSE 8080
CMD ["node", "server.js"]

Setting up GitHub Actions for container deployment

Push a tag. GitHub Actions builds. Lightsail deploys.

Create .github/workflows/deploy.yml:

name: Deploy to Lightsail (on tag)

on:
  push:
    tags:
      # 12-digit timestamp tags
      - '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]'
      # Optional env suffixes
      - '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-staging'
      - '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-prod'

jobs:
  deploy:
    runs-on: ubuntu-latest

    env:
      AWS_REGION: ${{ secrets.AWS_REGION }}
      LIGHTSAIL_SERVICE_NAME: ${{ secrets.LIGHTSAIL_SERVICE_NAME }}
      LIGHTSAIL_CONTAINER_NAME: ${{ secrets.LIGHTSAIL_CONTAINER_NAME }}
      LIGHTSAIL_PUBLIC_PORT: ${{ secrets.LIGHTSAIL_PUBLIC_PORT }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Install AWS CLI + jq
        run: |
          sudo apt-get update
          sudo apt-get install -y jq

      - name: Set version from tag
        run: |
          echo "APP_VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV

      - name: Get Lightsail registry auth + endpoint
        id: ls_registry
        run: |
          aws lightsail get-container-services-registry-login \
            --region "$AWS_REGION" \
            --output json > registry.json

          echo "REGISTRY_USERNAME=$(jq -r '.registryLogin.username' registry.json)" >> $GITHUB_ENV
          echo "REGISTRY_PASSWORD=$(jq -r '.registryLogin.password' registry.json)" >> $GITHUB_ENV
          echo "REGISTRY_ENDPOINT=$(jq -r '.registryLogin.registry' registry.json)" >> $GITHUB_ENV

      - name: Docker login to Lightsail registry
        run: |
          echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_ENDPOINT" -u "$REGISTRY_USERNAME" --password-stdin

      - name: Build image
        run: |
          docker build -t "$LIGHTSAIL_CONTAINER_NAME:$APP_VERSION" .

      - name: Tag + push image
        run: |
          IMAGE_URI="$REGISTRY_ENDPOINT/$LIGHTSAIL_CONTAINER_NAME:$APP_VERSION"
          docker tag "$LIGHTSAIL_CONTAINER_NAME:$APP_VERSION" "$IMAGE_URI"
          docker push "$IMAGE_URI"
          echo "IMAGE_URI=$IMAGE_URI" >> $GITHUB_ENV

      - name: Create Lightsail deployment JSON
        run: |
          cat > containers.json <<EOF
          {
            "containers": {
              "${LIGHTSAIL_CONTAINER_NAME}": {
                "image": "${IMAGE_URI}",
                "environment": {
                  "APP_VERSION": "${APP_VERSION}"
                },
                "ports": {
                  "${LIGHTSAIL_PUBLIC_PORT}": "HTTP"
                }
              }
            },
            "publicEndpoint": {
              "containerName": "${LIGHTSAIL_CONTAINER_NAME}",
              "containerPort": ${LIGHTSAIL_PUBLIC_PORT},
              "healthCheck": {
                "healthyThreshold": 2,
                "unhealthyThreshold": 2,
                "timeoutSeconds": 5,
                "intervalSeconds": 10,
                "path": "/health",
                "successCodes": "200-399"
              }
            }
          }
          EOF

      - name: Deploy to Lightsail
        run: |
          aws lightsail create-container-service-deployment \
            --region "$AWS_REGION" \
            --service-name "$LIGHTSAIL_SERVICE_NAME" \
            --containers file://containers.json \
            --public-endpoint file://containers.json

You'll notice this workflow does the same steps every time, in the same order. That's the point.


Adding Secrets to GitHub

In your GitHub repo:

Settings - Secrets and variables - Actions - New repository secret

Add these:


Deploying to AWS Lightsail Container Service

Step 1: Create a Container Service

In AWS Lightsail:

  1. Go to Containers
  2. Create container service
  3. Pick a size (start small; you can scale later)
  4. Give it a name that matches LIGHTSAIL_SERVICE_NAME

Step 2: Add a health check endpoint

Add a /health endpoint in your app. Keep it dumb:

@app.route("/health")
def health():
    return {"status": "ok"}, 200

Without this, Lightsail won't know if your app is alive or dead. It'll assume dead and keep restarting containers. Over, and over, and over, like a machine!

Step 3: First deployment

Your first deploy can be done by pushing a tag:

tag=$(date +%Y%m%d%H%M)
git tag $tag
git push origin $tag

Then watch the GitHub Actions run. After it finishes, Lightsail should show a new deployment and a public URL.


Viewing Container Logs

Two ways:

Lightsail console

Lightsail - your container service - Deployments / Logs (AWS moves this around occasionally, but it's always in the service view).

CLI

aws lightsail get-container-log \
  --service-name YOUR_SERVICE \
  --container-name YOUR_CONTAINER \
  --region YOUR_REGION

Log to stdout/stderr from your app. Don't write log files inside the container.

A small habit that pays off: log the version on startup.

import os, logging
logging.basicConfig(level=logging.INFO)
logging.info("Starting app version=%s", os.getenv("APP_VERSION", "unknown"))

Zero-downtime deployments

Lightsail does rolling deployments using the health check gate.

Your job:

If you need deeper checks, add a separate endpoint like /ready for your own monitoring.


Rollback Strategy

Something broke. Time to rollback.

Option 1: Deploy the previous tag (best)

Find the previous tag and redeploy it:

git tag -l --sort=-version:refname | head -10
git push origin 202501201400

This assumes your pipeline deploys on tag push and the older tag still exists.

Option 2: Manual rollback in Lightsail (fastest in a panic)

In Lightsail, pick the last good deployment and redeploy it from the console. This is the "I need the site back up now" button.

Option 3: Nuclear option (stop the bleeding)

Scale the service down or disable the public endpoint while you fix it. It's not elegant, but it's better than serving broken database writes.


Database Migrations with Containers

Your container needs to run migrations. Two ways to do this:

Option A: Run migrations in GitHub Actions

Add a step before the Lightsail deploy that runs your migration command against the prod DB.

Example (pseudo):

- name: Run migrations
  run: |
    python -m alembic upgrade head
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL_PROD }}

This is my default because a failed migration stops the deploy.

Option B: Run migrations at container startup

You can run migrations in your container entrypoint before starting the web server. This keeps CI simpler, but you need to ensure only one instance runs migrations. This isn't the best option, but lots of people do it, so it is "one way."


Optimizing Docker Images

A few easy wins:

If your image is huge, your deploy will feel huge.


Troubleshooting Common Issues

Health check fails
- App isn't listening on 0.0.0.0
- Wrong port (Lightsail expects what you declare)
- /health route missing or returning non-200

Deploy succeeds but app errors at runtime
- Missing env vars (set them in the deployment JSON / Lightsail config)
- Missing system packages (add them in the Dockerfile)
- Wrong start command (Gunicorn module path, Node entry file, etc.)

Actions can't push to registry
- AWS credentials don't have Lightsail permissions
- Wrong region (Lightsail resources are region-scoped)
- Service name / container name mismatch


Real Deployment Example

Here's what an actual deploy looks like:

## In Replit (or locally)
git status
git add -A
git commit -m "Fix checkout flow and add /health"
git push

## Ship it
tag=$(date +%Y%m%d%H%M)
git tag $tag
git push origin $tag

Example output tag:

Then:


Action Items


Red Flags


Key Takeaways


Resources

-Sethers

Share this post
Newsletter

One email, once a week.

Notes on databases, systems, and the occasional strong opinion about Python. No spam, unsubscribe anytime.

Seth Black
Written by

Seth Black

Engineer and founder based in Texas. Writes about databases, AI, and running things in production. Embeds in small teams as lead engineer.

More from Programming

View all →
Programming

What's the Differences Between Relational, NoSQL, Vectors, and Caches

Apr 14, 2026
Programming

Git and Source Control in AI Land

Apr 14, 2026
Programming

Domains, DNS, and why it feels fragile

Apr 14, 2026