all systems operationalIndia

Streamlit Portfolio Template

A portfolio and resume website written entirely in Python. Edit one data file, run one command, and you have a dark-themed, animated personal site with a working contact form.

state
healthy
stage
evergreen
status
open source
started
2026.09
source
github ↗
topics

A portfolio site shouldn't require learning a frontend framework. This template builds a complete personal site (hero, experience timeline, projects, skills, education, certifications and a contact form) using only Python and Streamlit.

All the content lives in one file, core/data.py. Change the dictionaries in it and the whole site updates. No HTML templates to hunt through, no build step.

Home screen of the template

Source code: github.com/sumit-kumar-03/streamlit-portfolio-template

What you get:

  • Eight sections behind a sidebar menu: Home, About, Experience, Projects, Skills, Education, Certifications and Contact.
  • A dark theme with gradient headings, hover cards and the Inter font, set through Streamlit's theme plus a small injected stylesheet.
  • Lottie animations, lightweight vector animations stored as JSON files, on the hero and section pages.
  • A contact form that validates input and saves each message to a local file. No email service or database needed.
  • Docker two ways: a self-contained image for deploying anywhere, and a Compose setup that mounts your project so saved changes reload automatically.

It started as an earlier version of this very website, which I then generalised into a template with placeholder content.

How it works

Streamlit runs your Python script from top to bottom on every interaction. The template uses that directly: the sidebar menu returns the selected section name, and app.py renders only that section.

core/data.py ──▶ app.py ──▶ sidebar option_menu ──▶ "Experience"?
 (content)        │                                     │
                  └── core/styles.py (CSS)              ▼
                                            core/components.py
                                            render_work_experience(...)
                                            → st.markdown(HTML card)
FileRole
app.pyPage config, SEO meta tags, CSS injection, the sidebar menu, and an if/elif block per section
core/data.pyAll your content: PERSONAL_INFO, SOCIAL_LINKS, WORK_EXPERIENCE, PROJECTS, SKILLS, EDUCATION, CERTIFICATIONS
core/components.pyrender_* functions that turn one piece of data into an HTML card or timeline item
core/styles.pyA single get_custom_css() string: font import, gradients, cards, badges, responsive rules, animations
core/enums.pyLoads the Lottie JSON files and holds the hero intro text
core/local_storage.pyAppends contact-form submissions to submissions/contact_submissions.txt
.streamlit/config.tomlTheme colours, server port, reload on save, usage stats turned off
lottiefiles/The animation files

Experience timeline with a stats card

Run it in five minutes

With Python (3.12 recommended):

git clone https://github.com/sumit-kumar-03/streamlit-portfolio-template.git
cd streamlit-portfolio-template
pip install -r requirements.txt
streamlit run app.py              # http://localhost:8501

With Docker, for editing (auto-reload included):

docker compose up --build         # http://localhost:8081

Compose mounts the project folder into the container, and runOnSave = true in .streamlit/config.toml makes Streamlit reload the moment you save a file.

With Docker, for deploying (a self-contained image):

docker build -t portfolio:latest .
docker run -p 8501:8501 portfolio:latest    # http://localhost:8501

Make it yours

  1. Content: replace the placeholders in core/data.py. It's plain Python dictionaries and lists, so add or remove entries freely.
  2. Intro text: edit InfoSection in core/enums.py.
  3. Resume: drop your PDF at cv/resume.pdf, then uncomment the Resume button in render_hero_section() in core/components.py.
  4. Colours: change primaryColor, backgroundColor and the others in .streamlit/config.toml, and the gradient stops in core/styles.py.
  5. Animations: download any animation as JSON from lottiefiles.com, put it in lottiefiles/, and point a Graphic entry in core/enums.py at it.

Build it yourself, step by step

This section rebuilds the template's core ideas from scratch, so you understand each piece and can take it further.

1. Set up

mkdir my-portfolio && cd my-portfolio
python3 -m venv .venv && source .venv/bin/activate
pip install streamlit streamlit-option-menu streamlit-lottie
mkdir core lottiefiles
touch core/__init__.py

2. Separate content from layout

Put everything a visitor reads into core/data.py. Your layout code then never contains personal text, and updating the site means editing data, not markup:

PERSONAL_INFO = {
    "name": "Your Name",
    "title": "Software Engineer",
    "tagline": "Backend | Distributed Systems · Cloud · APIs",
    "bio": "A short paragraph about you.",
    "email": "you@example.com",
    "location": "City, Country",
}

PROJECTS = [
    {
        "name": "Sample Project",
        "description": "What it does and why.",
        "tech_stack": ["Python", "FastAPI"],
        "highlights": ["Key result"],
        "github": "https://github.com/you/project",
        "demo": "",
    },
]

3. Page config and sidebar navigation

st.set_page_config must be the first Streamlit call. option_menu gives a styled menu with icons and returns the selected label:

import streamlit as st
from streamlit_option_menu import option_menu
from core.data import PERSONAL_INFO, PROJECTS

st.set_page_config(page_title=f"{PERSONAL_INFO['name']} - Portfolio", page_icon="👤", layout="wide")

with st.sidebar:
    st.markdown(f"## {PERSONAL_INFO['name']}")
    selected = option_menu(
        menu_title="Navigation",
        options=["Home", "Projects", "Contact"],
        icons=["house", "rocket", "envelope"],     # Bootstrap icon names
        default_index=0,
    )

if selected == "Home":
    ...
elif selected == "Projects":
    ...

4. Render data with small component functions

Each render_* function takes one item from data.py and draws it. Streamlit's own widgets can't do custom card layouts, so the template writes HTML with st.markdown(..., unsafe_allow_html=True) and styles it with CSS classes:

def render_project_card(project):
    st.markdown(f"""
    <div class="project-card">
        <div class="project-title">{project['name']}</div>
        <p>{project['description']}</p>
    </div>
    """, unsafe_allow_html=True)

    for highlight in project.get("highlights", []):
        st.markdown(f"- {highlight}")

    badges = " ".join(f'<span class="tech-badge">{t}</span>' for t in project.get("tech_stack", []))
    st.markdown(badges, unsafe_allow_html=True)

    if project.get("github"):
        st.markdown(f"[GitHub]({project['github']})")

Then the Projects section is just a loop: for p in PROJECTS: render_project_card(p).

unsafe_allow_html is fine here because the HTML comes from your own data file. Never pass visitor input through it.

5. Style it

Streamlit's theme covers the base colours:

# .streamlit/config.toml
[theme]
primaryColor = "#6366f1"
backgroundColor = "#0e1117"
secondaryBackgroundColor = "#1a1d24"
textColor = "#fafafa"

Anything beyond that goes into one injected stylesheet:

def get_custom_css():
    return """
    <style>
    @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
    html, body, [class*="css"] { font-family: 'Inter', sans-serif; }
    .project-card {
        background: linear-gradient(135deg, #1a1d24 0%, #262b36 100%);
        border-radius: 12px; padding: 1.5rem; transition: transform .2s;
    }
    .project-card:hover { transform: translateY(-4px); }
    .tech-badge {
        background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
        border-radius: 999px; padding: .2rem .7rem; font-size: .8rem;
    }
    </style>
    """

st.markdown(get_custom_css(), unsafe_allow_html=True)

6. Add animations

A Lottie file is a JSON description of a vector animation. Load it once and render it with st_lottie:

import json
from streamlit_lottie import st_lottie

def load_lottie_file(path):
    with open(path) as f:
        return json.load(f)

st_lottie(load_lottie_file("lottiefiles/coding-boy.json"), height=350, key="hero")

Give each st_lottie call a unique key, or Streamlit complains about duplicate elements.

7. A contact form without a backend

st.form groups inputs so the script only reruns when the visitor presses submit. clear_on_submit empties the fields afterwards:

from datetime import datetime
import os

with st.form("contact_form", clear_on_submit=True):
    name = st.text_input("Name *")
    email = st.text_input("Email *")
    message = st.text_area("Message *", height=150)
    sent = st.form_submit_button("Send Message")

if sent:
    if not name or not email or not message:
        st.error("Please fill in all required fields.")
    elif "@" not in email or "." not in email:
        st.error("Please enter a valid email address.")
    else:
        os.makedirs("submissions", exist_ok=True)
        with open("submissions/contact_submissions.txt", "a", encoding="utf-8") as f:
            f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} | {name} <{email}>\n{message}\n{'=' * 40}\n")
        st.success("Thanks! I'll get back to you soon.")

This is perfect for a personal site. If you get a lot of messages, swap the file write for an email API or a database. Keep submissions/ out of git, as the template's .gitignore does.

Contact page with the form and a direct-email card

8. Docker: one image, two ways to run it

The Dockerfile builds a self-contained image. Dependencies are installed first, so editing your code doesn't reinstall them on every build:

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /usr/src/app

COPY requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
EXPOSE 8501
ENTRYPOINT ["/usr/src/app/scripts/entrypoint.sh"]

scripts/entrypoint.sh just starts the app. exec replaces the shell, so Docker's stop signal reaches Streamlit directly:

#!/bin/bash
exec streamlit run /usr/src/app/app.py

For editing, docker-compose.yml runs the same image but mounts your project over the copied code, so the container always sees your latest files:

services:
  portfolio:
    image: portfolio:latest
    build: .
    volumes:
      - ./:/usr/src/app/
    ports:
      - "8081:8501"
    restart: unless-stopped

With runOnSave = true under [server] in .streamlit/config.toml, saving a file reloads the page in your browser. There's no file-watcher script to maintain; Streamlit does it for you.

For deploying, build and run the image on its own, as in "Run it in five minutes" above. Keep private files out of it with .dockerignore. The template excludes .git and any saved contact-form messages.

9. Put it online

  • Streamlit Community Cloud: push to GitHub, connect the repo at share.streamlit.io, and pick app.py. It's free for public repos.
  • Any container host: push the image from step 8 to a registry and run it on Cloud Run, ECS, Fly.io and similar.
  • Your own machine: run it with Docker and publish it through a Cloudflare Tunnel, with no open ports.

Ideas to extend it

  • Add a blog section that renders Markdown files from a posts/ folder.
  • Show GitHub stats by calling the GitHub API and caching the result with @st.cache_data.
  • Use the skill scores already in SKILLS (0–100) to draw radar or bar charts with Plotly, which is already in requirements.txt.
  • Offer a light/dark toggle by swapping the CSS string based on a sidebar switch.

Connected

shares a topic with this project