2026年7月15日

2026年7月15日

PythonのFlaskでWebアプリを作る方法|メモアプリで学ぶルーティング・テンプレート・SQLite・CSRF対策

Flask は、少ないコードで Web アプリを作れる Python のフレームワークです。この記事では、仮想環境の準備から始めて、メモを保存・一覧・削除できる小さなアプリを SQLite で作り、テンプレート、フォームの受け取り、CSRF 対策、SQL インジェクションを防ぐ書き方までを一通り説明します。最後に、作ったアプリを本番に出すときに開発用サーバーから何を切り替えるか(Gunicorn・Nginx・秘密鍵の扱い)も整理します。

Flask が向いている場面

Flask は、URL と処理を結びつける仕組み(ルーティング)、テンプレート、リクエストの扱いといった中心部分だけを持ち、ログインやフォーム検証、データベースの操作などは拡張ライブラリで足していく設計です。

  • 向いている:社内向けの小さなツール、API サーバー、管理用の画面、Python のスクリプトに Web の画面を付けたいとき
  • ほかも検討したい:ユーザー管理や管理画面まで最初から必要な大きめのサービス(Django のように機能がそろったフレームワークのほうが早いことがあります)

この記事のコードは Flask 3 系を前提にしています(2026年10月現在)。Flask が対応する Python のバージョンはリリースごとに見直されるため、新しく環境を作るときは Flask の公式ドキュメントの「Installation」で確認してください。

準備:仮想環境と Flask のインストール

プロジェクトごとに仮想環境を作り、その中に Flask と、CSRF 対策に使う Flask-WTF を入れます。

mkdir memo-app && cd memo-app
python3 -m venv .venv
source .venv/bin/activate        # Windows の PowerShell では .venv\Scripts\Activate.ps1
pip install flask flask-wtf
flask --version

仮想環境を使うのは、プロジェクトごとにライブラリのバージョンを分けるためです。動作した組み合わせは pip freeze > requirements.txt で記録しておくと、別のサーバーでも同じ環境を再現できます。

最小のアプリを動かす

まずは1ページだけのアプリで、起動の流れを確認します。

# hello.py
from flask import Flask

app = Flask(__name__)


@app.get("/")
def index():
    return "Hello, Flask!"
flask --app hello run --debug

ブラウザで http://127.0.0.1:5000/ を開くと文字が表示されます。@app.get("/") は「/ への GET リクエストでこの関数を呼ぶ」という指定です。--debug を付けると、コードを保存したときに自動で再起動し、エラー時にはブラウザに詳しい情報が出ます。

デバッグモードは開発中の手元だけで使ってください。 エラー画面から Python のコードを実行できるデバッガーが有効になるため、外から接続できる状態で動かすと、サーバーを乗っ取られる危険があります。

メモアプリを作る

ここからは、メモを保存・表示・削除できるアプリを作ります。ファイル構成は次のとおりです。

memo-app/
├── app.py
└── templates/
    └── index.html

app.py

# app.py
import os
import sqlite3
from pathlib import Path

from flask import Flask, abort, flash, g, redirect, render_template, request, url_for
from flask_wtf.csrf import CSRFProtect

BASE_DIR = Path(__file__).resolve().parent

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],  # 未設定なら起動時にエラーにする
    DATABASE=os.environ.get("MEMO_DB", str(BASE_DIR / "memo.sqlite3")),
    MAX_CONTENT_LENGTH=1 * 1024 * 1024,  # リクエストの大きさを 1MB までに制限
)
csrf = CSRFProtect(app)


def get_db() -> sqlite3.Connection:
    if "db" not in g:
        g.db = sqlite3.connect(app.config["DATABASE"])
        g.db.row_factory = sqlite3.Row
    return g.db


@app.teardown_appcontext
def close_db(exc):
    db = g.pop("db", None)
    if db is not None:
        db.close()


@app.cli.command("init-db")
def init_db():
    """テーブルを作る(既にあれば何もしない)"""
    db = get_db()
    db.executescript(
        """
        CREATE TABLE IF NOT EXISTS memos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            body TEXT NOT NULL,
            created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
        """
    )
    db.commit()
    print("データベースを初期化しました")


@app.get("/")
def index():
    memos = get_db().execute(
        "SELECT id, body, created_at FROM memos ORDER BY id DESC LIMIT 50"
    ).fetchall()
    return render_template("index.html", memos=memos)


@app.post("/memos")
def create_memo():
    body = request.form.get("body", "").strip()
    if not body or len(body) > 500:
        flash("メモは1〜500文字で入力してください")
        return redirect(url_for("index"))
    db = get_db()
    db.execute("INSERT INTO memos (body) VALUES (?)", (body,))
    db.commit()
    flash("保存しました")
    return redirect(url_for("index"))


@app.post("/memos/<int:memo_id>/delete")
def delete_memo(memo_id: int):
    db = get_db()
    cur = db.execute("DELETE FROM memos WHERE id = ?", (memo_id,))
    db.commit()
    if cur.rowcount == 0:
        abort(404)
    flash("削除しました")
    return redirect(url_for("index"))


@app.get("/healthz")
def healthz():
    return {"status": "ok"}

templates/index.html

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>メモ帳</title>
</head>
<body>
  <h1>メモ帳</h1>

  {% with messages = get_flashed_messages() %}
    {% if messages %}
      <ul>{% for message in messages %}<li>{{ message }}</li>{% endfor %}</ul>
    {% endif %}
  {% endwith %}

  <form method="post" action="{{ url_for('create_memo') }}">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <textarea name="body" rows="3" maxlength="500" required></textarea>
    <button type="submit">保存</button>
  </form>

  <ul>
  {% for memo in memos %}
    <li>
      {{ memo["body"] }} <small>{{ memo["created_at"] }}</small>
      <form method="post" action="{{ url_for('delete_memo', memo_id=memo['id']) }}">
        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
        <button type="submit">削除</button>
      </form>
    </li>
  {% else %}
    <li>メモはまだありません</li>
  {% endfor %}
  </ul>
</body>
</html>

起動する

秘密鍵(SECRET_KEY)はランダムな値を作って環境変数で渡します。コードやリポジトリには書きません。

export FLASK_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_hex(32))')"
flask --app app init-db
flask --app app run --debug

Windows の PowerShell では、$env:FLASK_SECRET_KEY = python -c "import secrets; print(secrets.token_hex(32))" のように設定します。

コードの要点:安全に作るための5つの決まりごと

小さなアプリでも、次の5点は最初から入れておくと後で困りません。

  1. SQL はプレースホルダーで値を渡す:"... WHERE id = ?" と (memo_id,) のように、SQL 文と値を分けて渡します。f 文字列で値を SQL に埋め込むと、入力内容によって SQL が書き換えられる SQL インジェクションの原因になります。
  2. テンプレートの自動エスケープに任せる:Flask は .html のテンプレートで {{ }} の中身を自動でエスケープするため、メモに <script> が書かれても文字として表示されます。利用者の入力に |safe を付けて、このエスケープを外さないでください。
  3. POST のフォームには CSRF トークンを入れる:CSRFProtect を有効にすると、トークンの無い POST は拒否されます。ほかのサイトに仕込まれたフォームから、利用者になりすまして削除などを実行されるのを防ぐ仕組みです。
  4. POST の後はリダイレクトする:保存や削除の後に redirect() で一覧へ戻すと、ブラウザの再読み込みで同じ投稿が二重に送られるのを防げます(PRG パターンと呼ばれます)。
  5. データを変える操作は POST にする:削除を GET のリンクにすると、リンクの先読みやクローラーのアクセスで消えてしまうことがあります。

flash() は次の画面に1回だけメッセージを渡す仕組みで、内部ではセッション(署名付きの Cookie)を使います。SECRET_KEY が無いと動かないのはこのためで、値が漏れると Cookie を偽造されるおそれがあります。

動作確認でつまずきやすい点

  • **KeyError: 'FLASK_SECRET_KEY'**:環境変数が未設定です。ターミナルを開き直すと消えるので、起動のたびに設定するか、開発時だけ .env ファイル(リポジトリには入れない)から読み込む方法を使います。
  • **no such table: memos**:flask --app app init-db を実行していないか、別の場所のデータベースファイルを見ています。MEMO_DB の値を確認してください。
  • **The CSRF token is missing.**:フォームに csrf_token の hidden 項目が無いか、name の綴りが違います。
  • **TemplateNotFound**:templates フォルダが app.py と同じ階層に無いか、フォルダ名が違います。

本番に公開するときに切り替えること

flask run は開発用のサーバーで、本番での利用は想定されていません。公開するときは次の構成に切り替えます。

  • WSGI サーバー:Gunicorn などで起動します(Gunicorn は Linux などの POSIX 環境向けで、Windows では動きません)。例: gunicorn -w 2 -b 127.0.0.1:8000 app:app
  • リバースプロキシ:Nginx を前に置き、HTTPS の終端と静的ファイルの配信を任せます。Flask 側は 127.0.0.1 だけで待ち受けます。
  • プロキシの情報を正しく受け取る:Nginx の後ろでは、接続元 IP や HTTPS かどうかがそのままでは Flask に伝わりません。Werkzeug の ProxyFix を、プロキシの段数に合わせて設定します。
from werkzeug.middleware.proxy_fix import ProxyFix

app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)  # Nginx が1段の場合
app.config.update(SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax")
  • 常駐と再起動:systemd のサービスにして、サーバーの再起動後も自動で立ち上がるようにします。秘密鍵などは EnvironmentFile で、権限を絞ったファイルから渡します。
  • 認証:この記事のアプリには利用者の区別が無く、URL を知っていれば誰でも削除できます。インターネットに公開するなら、Flask-Login などでログインを付けるか、Nginx の Basic 認証や IP 制限で利用者を絞ってください。
  • バックアップ:SQLite のデータベースは1つのファイルなので、定期的に別の場所へコピーします。動作中にコピーする場合は、sqlite3 コマンドの .backup を使うと書き込み途中の状態を避けられます。

まとめ

  • 仮想環境に Flask を入れ、flask --app app run --debug で開発する。デバッグモードは手元だけで使う
  • ルートは @app.get / @app.post、画面は render_template とテンプレートで作り、POST の後はリダイレクトする
  • SQL はプレースホルダー、出力はテンプレートの自動エスケープ、フォームは CSRF トークンで守る
  • SECRET_KEY はランダムな値を環境変数で渡し、コードやリポジトリに書かない
  • 本番は Gunicorn と Nginx に切り替え、ProxyFix・Cookie の設定・認証・バックアップを整える

お気軽にご相談ください

お見積りへ お問い合わせへ
▲