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:
- 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).
- 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:
- Replit is where you code.
- GitHub is where you version and trigger automation.
- Lightsail is where the container runs, with health checks and a stable URL.
- Tags are the big red button that starts the whole thing.
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
- They sort correctly. Newer tags are bigger numbers.
- They're self-explaining. "We deployed the 1430 build" is clear.
- They avoid arguments. Nobody debates minor vs patch.
- They're searchable. You can line up an incident timeline with deploy times.
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:
- Your app runs with the same OS + runtime + dependencies everywhere.
- The server stops being a weird pet you have to hand-feed.
- Rollback becomes "deploy the old image."
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:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_REGION(example:us-east-1)LIGHTSAIL_SERVICE_NAME(example:my-app-service)LIGHTSAIL_CONTAINER_NAME(example:web)LIGHTSAIL_PUBLIC_PORT(example:8080)
Deploying to AWS Lightsail Container Service
Step 1: Create a Container Service
In AWS Lightsail:
- Go to Containers
- Create container service
- Pick a size (start small; you can scale later)
- 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:
- Make sure the container starts quickly.
- Make sure
/healthreturns 200 once the app can serve traffic. - Don't put slow database checks in
/health.
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:
- Use
python:*-slimornode:*-slimunless you have a reason not to. - Keep
.dockerignoretight. - Don't install dev dependencies in production images.
- If builds are slow, look at what changes bust the cache (usually copying the whole repo too early).
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:
202501201430(Jan 20, 2025 at 2:30 PM)
Then:
- GitHub Actions builds
web:202501201430 - Pushes it to the Lightsail registry
- Creates a Lightsail deployment
- Lightsail waits for
/healthto pass - Traffic shifts
Action Items
- Add
/healthto your app. - Add a Dockerfile that runs your app on
0.0.0.0:8080. - Add
.dockerignore. - Create a Lightsail Container Service.
- Add GitHub Actions secrets.
- Push a timestamp tag and watch one full deploy end-to-end.
- Practice one rollback while you're calm.
Red Flags
- "Just SSH in and change it real quick."
- Tags that get deleted and recreated.
- Deploys that require one specific person to be online.
- Health checks that hit the database (great way to turn a small outage into a redeploy loop).
- Containers that write logs to files and then you can't find them.
Key Takeaways
- A deploy should be a single, repeatable action.
- Tags are a clean deploy trigger and a clean audit trail.
- Containers make "what's running?" a factual question.
- Health checks are what turn "deploy" into "deploy safely."
- Rollback should be the same pipeline as deploy.
Resources
- AWS Lightsail Container Service docs: https://docs.aws.amazon.com/lightsail/
- GitHub Actions docs: https://docs.github.com/actions
- Dockerfile reference: https://docs.docker.com/engine/reference/builder/
- Gunicorn docs: https://docs.gunicorn.org/
-Sethers